Skip to main content

aisimulate_core/engine/
config.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Serializable engine configuration.
5
6use std::sync::Arc;
7
8use anyhow::{Result, ensure};
9use serde::{Deserialize, Deserializer, Serialize};
10
11use crate::engine::common::speculative::normalize_conditional_accept_rates;
12use crate::engine::handoff::TransferTimingMode;
13use crate::engine::timing::{TimingModel, TimingModelConfig, built_in_timing_model};
14
15const DEFAULT_MAX_PREFILL_TOKENS: usize = 16_384;
16const DEFAULT_CHUNKED_PREFILL_SIZE: usize = 8_192;
17const DEFAULT_CLIP_MAX_NEW_TOKENS: usize = 4_096;
18const DEFAULT_SCHEDULE_CONSERVATIVENESS: f64 = 1.0;
19const DEFAULT_HOST_OFFLOAD_BANDWIDTH_GBPS: f64 = 32.0;
20
21fn default_num_gpu_blocks() -> usize {
22    16_384
23}
24
25fn default_block_size() -> usize {
26    64
27}
28
29fn default_max_num_seqs() -> usize {
30    256
31}
32
33fn default_max_num_batched_tokens() -> usize {
34    8_192
35}
36
37fn default_true() -> bool {
38    true
39}
40
41fn default_one() -> f64 {
42    1.0
43}
44
45fn default_aic_mtp_seed() -> u64 {
46    42
47}
48
49fn default_max_prefill_tokens() -> usize {
50    DEFAULT_MAX_PREFILL_TOKENS
51}
52
53fn default_chunked_prefill_size() -> usize {
54    DEFAULT_CHUNKED_PREFILL_SIZE
55}
56
57fn default_clip_max_new_tokens() -> usize {
58    DEFAULT_CLIP_MAX_NEW_TOKENS
59}
60
61fn default_schedule_conservativeness() -> f64 {
62    DEFAULT_SCHEDULE_CONSERVATIVENESS
63}
64
65fn default_host_offload_bandwidth_gbps() -> f64 {
66    DEFAULT_HOST_OFFLOAD_BANDWIDTH_GBPS
67}
68
69/// Scheduler semantics selected for an AISimulate rank.
70#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum Backend {
73    /// vLLM-style block scheduling.
74    #[default]
75    Vllm,
76    /// SGLang-style radix-cache scheduling.
77    Sglang,
78    /// TensorRT-LLM scheduling through the shared vLLM-style core.
79    Trtllm,
80}
81
82impl Backend {
83    /// Backend-native KV block size used when a caller does not provide one.
84    pub const fn default_block_size(self) -> usize {
85        match self {
86            Self::Vllm => 64,
87            Self::Sglang => 1,
88            Self::Trtllm => 32,
89        }
90    }
91}
92
93/// Scheduler role.
94#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum WorkerType {
97    /// Prefill and decode execute on the same rank.
98    #[default]
99    Aggregated,
100    /// The rank emits its first token with no separate decode latency.
101    Prefill,
102    /// The rank performs decode work only.
103    Decode,
104}
105
106/// Decode preemption victim selection.
107#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case")]
109pub enum PreemptionMode {
110    /// Evict the most recently admitted runnable request.
111    #[default]
112    Lifo,
113    /// Evict the oldest runnable request.
114    Fifo,
115}
116
117/// SGLang waiting-queue ordering.
118#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum SglangSchedulePolicy {
121    /// First-in, first-out.
122    #[default]
123    Fifo,
124    /// Longest cached-prefix first for bounded waiting queues.
125    Lpm,
126}
127
128/// Serializable SGLang scheduler controls.
129#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
130#[serde(default, deny_unknown_fields)]
131pub struct SglangConfig {
132    /// Waiting-queue policy.
133    pub schedule_policy: SglangSchedulePolicy,
134    /// Page-aware prefill-token budget per pass.
135    #[serde(default = "default_max_prefill_tokens")]
136    pub max_prefill_tokens: usize,
137    /// Maximum prompt chunk considered in one pass.
138    #[serde(default = "default_chunked_prefill_size")]
139    pub chunked_prefill_size: usize,
140    /// Output reservation cap used by SGLang admission control.
141    #[serde(default = "default_clip_max_new_tokens")]
142    pub clip_max_new_tokens: usize,
143    /// Multiplier applied to SGLang's adaptive output-reservation ratio.
144    #[serde(default = "default_schedule_conservativeness")]
145    pub schedule_conservativeness: f64,
146}
147
148impl Default for SglangConfig {
149    fn default() -> Self {
150        Self {
151            schedule_policy: SglangSchedulePolicy::Fifo,
152            max_prefill_tokens: default_max_prefill_tokens(),
153            chunked_prefill_size: default_chunked_prefill_size(),
154            clip_max_new_tokens: default_clip_max_new_tokens(),
155            schedule_conservativeness: default_schedule_conservativeness(),
156        }
157    }
158}
159
160impl SglangConfig {
161    pub(crate) fn validate(&self) -> Result<()> {
162        ensure!(
163            self.max_prefill_tokens > 0,
164            "sglang.max_prefill_tokens must be positive"
165        );
166        ensure!(
167            self.chunked_prefill_size > 0,
168            "sglang.chunked_prefill_size must be positive"
169        );
170        ensure!(
171            self.schedule_conservativeness.is_finite() && self.schedule_conservativeness >= 0.0,
172            "sglang.schedule_conservativeness must be finite and non-negative"
173        );
174        Ok(())
175    }
176}
177
178/// TensorRT-LLM capacity scheduler policy.
179///
180/// The mocker currently models the TensorRT-LLM default only. Keeping
181/// the policy explicit prevents a config from silently falling back to vLLM
182/// admission or preemption semantics.
183#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "snake_case")]
185pub enum TrtllmCapacityPolicy {
186    /// Reserve each admitted request through completion and never evict it.
187    #[default]
188    GuaranteedNoEvict,
189}
190
191/// Serializable TensorRT-LLM scheduler controls.
192#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(default, deny_unknown_fields)]
194pub struct TrtllmConfig {
195    /// Capacity scheduler policy.
196    pub capacity_scheduler_policy: TrtllmCapacityPolicy,
197}
198
199/// Physical controls for framework-native G1-to-host offload.
200///
201/// Framework policy remains selected by [`EngineConfig::backend`]. This
202/// descriptor intentionally contains only shared capacity and transfer
203/// parameters so additional framework profiles can reuse it without exposing
204/// unsupported policy combinations. Physical bytes per block are derived from
205/// [`EngineConfig::block_size`] and [`EngineConfig::kv_cache_bytes_per_token`].
206#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
207#[serde(deny_unknown_fields)]
208#[non_exhaustive]
209pub struct NativeHostOffloadConfig {
210    /// Physical host-cache capacity in KV blocks.
211    pub num_host_blocks: usize,
212    /// Modeled device-to-host bandwidth in decimal GB/s. Zero is instantaneous.
213    #[serde(default = "default_host_offload_bandwidth_gbps")]
214    pub d2h_bandwidth_gbps: f64,
215    /// Modeled host-to-device bandwidth in decimal GB/s. Zero is instantaneous.
216    #[serde(default = "default_host_offload_bandwidth_gbps")]
217    pub h2d_bandwidth_gbps: f64,
218}
219
220impl NativeHostOffloadConfig {
221    pub const fn new(num_host_blocks: usize) -> Self {
222        Self {
223            num_host_blocks,
224            d2h_bandwidth_gbps: DEFAULT_HOST_OFFLOAD_BANDWIDTH_GBPS,
225            h2d_bandwidth_gbps: DEFAULT_HOST_OFFLOAD_BANDWIDTH_GBPS,
226        }
227    }
228
229    pub const fn with_bandwidths(mut self, d2h_gbps: f64, h2d_gbps: f64) -> Self {
230        self.d2h_bandwidth_gbps = d2h_gbps;
231        self.h2d_bandwidth_gbps = h2d_gbps;
232        self
233    }
234
235    fn validate(&self) -> Result<()> {
236        ensure!(
237            self.num_host_blocks > 0,
238            "native_host_offload.num_host_blocks must be positive"
239        );
240        ensure!(
241            self.d2h_bandwidth_gbps.is_finite() && self.d2h_bandwidth_gbps >= 0.0,
242            "native_host_offload.d2h_bandwidth_gbps must be finite and non-negative"
243        );
244        ensure!(
245            self.h2d_bandwidth_gbps.is_finite() && self.h2d_bandwidth_gbps >= 0.0,
246            "native_host_offload.h2d_bandwidth_gbps must be finite and non-negative"
247        );
248        Ok(())
249    }
250}
251
252/// Serializable configuration for one scheduler rank.
253///
254/// Attention-DP size and worker identity belong to
255/// [`crate::engine::generalized::GeneralizedEngineConfig`] and
256/// [`crate::engine::generalized::EngineIdentity`], not this rank-local configuration.
257///
258/// [`Default`] constructs a vLLM configuration. Changing only [`Self::backend`]
259/// afterward does not recompute backend-dependent fields such as
260/// [`Self::block_size`]; start with [`Self::for_backend`] when constructing a
261/// different backend in Rust. Deserialization selects the backend's block-size
262/// default when `block_size` is omitted.
263#[derive(Debug, Clone, PartialEq, Serialize)]
264pub struct EngineConfig {
265    /// Scheduler backend whose semantics this rank executes.
266    ///
267    /// Use [`Self::for_backend`] instead of changing this field on
268    /// [`Self::default`] when backend-dependent defaults are desired.
269    pub backend: Backend,
270    /// Physical G1 capacity in blocks.
271    #[serde(default = "default_num_gpu_blocks")]
272    pub num_gpu_blocks: usize,
273    /// KV block size in tokens.
274    #[serde(default = "default_block_size")]
275    pub block_size: usize,
276    /// Optional model context limit.
277    pub max_model_len: Option<usize>,
278    /// Maximum concurrently runnable sequences.
279    #[serde(default = "default_max_num_seqs")]
280    pub max_num_seqs: usize,
281    /// Per-pass token budget.
282    #[serde(default = "default_max_num_batched_tokens")]
283    pub max_num_batched_tokens: usize,
284    /// Whether complete blocks remain reusable after request release.
285    #[serde(default = "default_true")]
286    pub enable_prefix_caching: bool,
287    /// Whether a prompt may be split across scheduler passes.
288    #[serde(default = "default_true")]
289    pub enable_chunked_prefill: bool,
290    /// Divisor applied to modeled prefill and decode latency.
291    #[serde(default = "default_one")]
292    pub speedup_ratio: f64,
293    /// Additional divisor applied to decode latency.
294    #[serde(default = "default_one")]
295    pub decode_speedup_ratio: f64,
296    /// MTP/EAGLE draft-token count. One verification forward can emit up to
297    /// `aic_nextn + 1` output tokens.
298    pub aic_nextn: Option<usize>,
299    /// Conditional draft acceptance rates, comma-separated.
300    ///
301    /// Entry `i` is the probability that draft `i` is accepted given that
302    /// every preceding draft was accepted.
303    pub aic_nextn_accept_rates: Option<String>,
304    /// Base seed for deterministic worker-local MTP acceptance sampling.
305    #[serde(default = "default_aic_mtp_seed")]
306    pub aic_mtp_seed: u64,
307    /// Scheduler role.
308    pub worker_type: WorkerType,
309    /// Decode preemption victim order.
310    pub preemption_mode: PreemptionMode,
311    /// Retain and expose local token-block hashes in neutral KV events.
312    pub emit_kv_events: bool,
313    /// Retain block token IDs alongside neutral KV events.
314    pub emit_kv_token_ids: bool,
315    /// Bytes transferred per prompt token for disaggregated handoff timing.
316    pub kv_transfer_bytes_per_token: Option<usize>,
317    /// Physical KV-cache bytes occupied by one token for host offload.
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub kv_cache_bytes_per_token: Option<usize>,
320    /// Optional framework-native host-offload simulation.
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub native_host_offload: Option<NativeHostOffloadConfig>,
323    /// Modeled prefill-to-decode transfer bandwidth in decimal GB/s.
324    pub kv_transfer_bandwidth: Option<f64>,
325    /// Prompt footprint used to model disaggregated transfer time.
326    pub kv_transfer_timing_mode: TransferTimingMode,
327    /// Serializable timing-provider descriptor.
328    pub timing_model: TimingModelConfig,
329    /// SGLang-only scheduler controls.
330    pub sglang: SglangConfig,
331    /// TensorRT-LLM-only scheduler controls.
332    pub trtllm: TrtllmConfig,
333}
334
335#[derive(Deserialize)]
336#[serde(deny_unknown_fields)]
337struct EngineConfigWire {
338    #[serde(default)]
339    backend: Backend,
340    #[serde(default = "default_num_gpu_blocks")]
341    num_gpu_blocks: usize,
342    #[serde(default)]
343    block_size: Option<usize>,
344    #[serde(default)]
345    max_model_len: Option<usize>,
346    #[serde(default = "default_max_num_seqs")]
347    max_num_seqs: usize,
348    #[serde(default = "default_max_num_batched_tokens")]
349    max_num_batched_tokens: usize,
350    #[serde(default = "default_true")]
351    enable_prefix_caching: bool,
352    #[serde(default = "default_true")]
353    enable_chunked_prefill: bool,
354    #[serde(default = "default_one")]
355    speedup_ratio: f64,
356    #[serde(default = "default_one")]
357    decode_speedup_ratio: f64,
358    #[serde(default)]
359    aic_nextn: Option<usize>,
360    #[serde(default)]
361    aic_nextn_accept_rates: Option<String>,
362    #[serde(default = "default_aic_mtp_seed")]
363    aic_mtp_seed: u64,
364    #[serde(default)]
365    worker_type: WorkerType,
366    #[serde(default)]
367    preemption_mode: PreemptionMode,
368    #[serde(default)]
369    emit_kv_events: bool,
370    #[serde(default)]
371    emit_kv_token_ids: bool,
372    #[serde(default, alias = "kv_bytes_per_token")]
373    kv_transfer_bytes_per_token: Option<usize>,
374    #[serde(default)]
375    kv_cache_bytes_per_token: Option<usize>,
376    #[serde(default)]
377    native_host_offload: Option<NativeHostOffloadConfig>,
378    #[serde(default)]
379    kv_transfer_bandwidth: Option<f64>,
380    #[serde(default)]
381    kv_transfer_timing_mode: TransferTimingMode,
382    #[serde(default)]
383    timing_model: TimingModelConfig,
384    #[serde(default)]
385    sglang: SglangConfig,
386    #[serde(default)]
387    trtllm: TrtllmConfig,
388}
389
390impl<'de> Deserialize<'de> for EngineConfig {
391    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
392    where
393        D: Deserializer<'de>,
394    {
395        let wire = EngineConfigWire::deserialize(deserializer)?;
396        Ok(Self {
397            backend: wire.backend,
398            num_gpu_blocks: wire.num_gpu_blocks,
399            block_size: wire
400                .block_size
401                .unwrap_or_else(|| wire.backend.default_block_size()),
402            max_model_len: wire.max_model_len,
403            max_num_seqs: wire.max_num_seqs,
404            max_num_batched_tokens: wire.max_num_batched_tokens,
405            enable_prefix_caching: wire.enable_prefix_caching,
406            enable_chunked_prefill: wire.enable_chunked_prefill,
407            speedup_ratio: wire.speedup_ratio,
408            decode_speedup_ratio: wire.decode_speedup_ratio,
409            aic_nextn: wire.aic_nextn,
410            aic_nextn_accept_rates: wire.aic_nextn_accept_rates,
411            aic_mtp_seed: wire.aic_mtp_seed,
412            worker_type: wire.worker_type,
413            preemption_mode: wire.preemption_mode,
414            emit_kv_events: wire.emit_kv_events,
415            emit_kv_token_ids: wire.emit_kv_token_ids,
416            kv_transfer_bytes_per_token: wire.kv_transfer_bytes_per_token,
417            kv_cache_bytes_per_token: wire.kv_cache_bytes_per_token,
418            native_host_offload: wire.native_host_offload,
419            kv_transfer_bandwidth: wire.kv_transfer_bandwidth,
420            kv_transfer_timing_mode: wire.kv_transfer_timing_mode,
421            timing_model: wire.timing_model,
422            sglang: wire.sglang,
423            trtllm: wire.trtllm,
424        })
425    }
426}
427
428impl Default for EngineConfig {
429    fn default() -> Self {
430        Self {
431            backend: Backend::Vllm,
432            num_gpu_blocks: default_num_gpu_blocks(),
433            block_size: default_block_size(),
434            max_model_len: None,
435            max_num_seqs: default_max_num_seqs(),
436            max_num_batched_tokens: default_max_num_batched_tokens(),
437            enable_prefix_caching: true,
438            enable_chunked_prefill: true,
439            speedup_ratio: 1.0,
440            decode_speedup_ratio: 1.0,
441            aic_nextn: None,
442            aic_nextn_accept_rates: None,
443            aic_mtp_seed: default_aic_mtp_seed(),
444            worker_type: WorkerType::Aggregated,
445            preemption_mode: PreemptionMode::Lifo,
446            emit_kv_events: false,
447            emit_kv_token_ids: false,
448            kv_transfer_bytes_per_token: None,
449            kv_cache_bytes_per_token: None,
450            native_host_offload: None,
451            kv_transfer_bandwidth: None,
452            kv_transfer_timing_mode: TransferTimingMode::FullPrompt,
453            timing_model: TimingModelConfig::Polynomial,
454            sglang: SglangConfig::default(),
455            trtllm: TrtllmConfig::default(),
456        }
457    }
458}
459
460impl EngineConfig {
461    /// Construct a configuration with the selected backend's native defaults.
462    ///
463    /// In particular, this selects [`Backend::default_block_size`] instead of
464    /// inheriting the vLLM block size from [`Self::default`].
465    pub fn for_backend(backend: Backend) -> Self {
466        Self {
467            backend,
468            block_size: backend.default_block_size(),
469            ..Self::default()
470        }
471    }
472
473    pub(crate) fn validate(&self) -> Result<()> {
474        ensure!(self.num_gpu_blocks > 0, "num_gpu_blocks must be positive");
475        ensure!(self.block_size > 0, "block_size must be positive");
476        if matches!(self.backend, Backend::Vllm | Backend::Trtllm) {
477            ensure!(
478                self.block_size >= 2,
479                "vLLM/TRT-LLM block_size must be at least two"
480            );
481        }
482        ensure!(self.max_num_seqs > 0, "max_num_seqs must be positive");
483        ensure!(
484            self.max_num_batched_tokens > 0,
485            "max_num_batched_tokens must be positive"
486        );
487        ensure!(
488            self.max_model_len.is_none_or(|limit| limit > 0),
489            "max_model_len must be positive"
490        );
491        ensure!(
492            self.backend == Backend::Vllm || self.max_model_len.is_none(),
493            "max_model_len is supported only for backend=vllm"
494        );
495        ensure!(
496            self.speedup_ratio.is_finite() && self.speedup_ratio >= 0.0,
497            "speedup_ratio must be finite and non-negative"
498        );
499        ensure!(
500            self.decode_speedup_ratio.is_finite() && self.decode_speedup_ratio >= 0.0,
501            "decode_speedup_ratio must be finite and non-negative"
502        );
503        if let Some(nextn) = self.aic_nextn {
504            normalize_conditional_accept_rates(nextn, self.aic_nextn_accept_rates.as_deref())?;
505            ensure!(
506                self.decode_speedup_ratio == 1.0,
507                "aic_nextn requires decode_speedup_ratio=1.0 because MTP output acceleration is modeled by burst sampling"
508            );
509        } else {
510            ensure!(
511                self.aic_nextn_accept_rates.is_none(),
512                "aic_nextn_accept_rates requires aic_nextn"
513            );
514        }
515        if self.backend == Backend::Sglang {
516            ensure!(
517                !self.emit_kv_token_ids,
518                "emit_kv_token_ids=true is not supported for backend=sglang"
519            );
520            ensure!(
521                self.enable_chunked_prefill,
522                "enable_chunked_prefill=false is not supported for backend=sglang"
523            );
524            self.sglang.validate()?;
525        }
526        ensure!(
527            !self.emit_kv_token_ids || self.emit_kv_events,
528            "emit_kv_token_ids requires emit_kv_events"
529        );
530        ensure!(
531            self.kv_transfer_bytes_per_token
532                .is_none_or(|bytes| bytes > 0),
533            "kv_transfer_bytes_per_token must be positive"
534        );
535        ensure!(
536            self.kv_cache_bytes_per_token.is_none_or(|bytes| bytes > 0),
537            "kv_cache_bytes_per_token must be positive"
538        );
539        if let Some(host_offload) = &self.native_host_offload {
540            host_offload.validate()?;
541            ensure!(
542                self.backend == Backend::Vllm,
543                "native_host_offload is supported only for backend=vllm"
544            );
545            ensure!(
546                self.worker_type == WorkerType::Aggregated,
547                "native_host_offload is supported only for worker_type=aggregated"
548            );
549            ensure!(
550                self.enable_prefix_caching,
551                "native_host_offload requires enable_prefix_caching=true"
552            );
553            ensure!(
554                self.aic_nextn.is_none(),
555                "native_host_offload does not support aic_nextn in the initial implementation"
556            );
557            let kv_bytes_per_token = self.kv_cache_bytes_per_token.ok_or_else(|| {
558                anyhow::anyhow!(
559                    "native_host_offload requires kv_cache_bytes_per_token to derive the physical host block size"
560                )
561            })?;
562            let block_bytes = self
563                .block_size
564                .checked_mul(kv_bytes_per_token)
565                .filter(|bytes| *bytes > 0)
566                .ok_or_else(|| {
567                    anyhow::anyhow!(
568                        "native_host_offload requires block_size * kv_cache_bytes_per_token to produce a positive, representable block size"
569                    )
570                })?;
571            let capacity_bytes = host_offload
572                .num_host_blocks
573                .checked_mul(block_bytes)
574                .ok_or_else(|| {
575                    anyhow::anyhow!("native_host_offload capacity in bytes overflowed")
576                })?;
577            for (name, bandwidth) in [
578                ("d2h_bandwidth_gbps", host_offload.d2h_bandwidth_gbps),
579                ("h2d_bandwidth_gbps", host_offload.h2d_bandwidth_gbps),
580            ] {
581                let bytes_per_ms = bandwidth * 1_000_000.0;
582                ensure!(
583                    bytes_per_ms.is_finite()
584                        && (bandwidth == 0.0 || (capacity_bytes as f64 / bytes_per_ms).is_finite()),
585                    "native_host_offload.{name} produces an unrepresentable transfer duration"
586                );
587            }
588        }
589        ensure!(
590            self.kv_transfer_bandwidth
591                .is_none_or(|bandwidth| bandwidth.is_finite() && bandwidth >= 0.0),
592            "kv_transfer_bandwidth must be finite and non-negative"
593        );
594        match &self.timing_model {
595            TimingModelConfig::Polynomial => {}
596            TimingModelConfig::Fixed {
597                prefill_ms,
598                decode_ms,
599            } => {
600                ensure!(
601                    prefill_ms.is_finite() && *prefill_ms >= 0.0,
602                    "fixed prefill latency must be finite and non-negative"
603                );
604                ensure!(
605                    decode_ms.is_finite() && *decode_ms >= 0.0,
606                    "fixed decode latency must be finite and non-negative"
607                );
608            }
609            TimingModelConfig::External { provider, .. } => {
610                ensure!(
611                    !provider.trim().is_empty(),
612                    "timing provider cannot be empty"
613                );
614            }
615        }
616        Ok(())
617    }
618
619    pub(crate) fn built_in_timing_model(&self) -> Result<Arc<dyn TimingModel>> {
620        built_in_timing_model(&self.timing_model)
621    }
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    type InvalidHostConfigCase = (fn(&mut EngineConfig), &'static str);
629
630    fn native_host_offload_config() -> EngineConfig {
631        EngineConfig {
632            block_size: 16,
633            kv_cache_bytes_per_token: Some(128 * 1024),
634            native_host_offload: Some(NativeHostOffloadConfig {
635                num_host_blocks: 4_096,
636                d2h_bandwidth_gbps: DEFAULT_HOST_OFFLOAD_BANDWIDTH_GBPS,
637                h2d_bandwidth_gbps: DEFAULT_HOST_OFFLOAD_BANDWIDTH_GBPS,
638            }),
639            ..EngineConfig::default()
640        }
641    }
642
643    fn assert_invalid_host_config(mutate: impl FnOnce(&mut EngineConfig), expected_message: &str) {
644        let mut config = native_host_offload_config();
645        mutate(&mut config);
646        assert!(
647            config
648                .validate()
649                .unwrap_err()
650                .to_string()
651                .contains(expected_message),
652            "validation error did not contain {expected_message:?}"
653        );
654    }
655
656    #[test]
657    fn deserialization_uses_backend_native_block_size() {
658        for (backend, expected) in [("vllm", 64), ("sglang", 1), ("trtllm", 32)] {
659            let config: EngineConfig =
660                serde_json::from_value(serde_json::json!({ "backend": backend })).unwrap();
661            assert_eq!(config.block_size, expected, "backend={backend}");
662        }
663    }
664
665    #[test]
666    fn for_backend_uses_backend_native_block_size() {
667        for backend in [Backend::Vllm, Backend::Sglang, Backend::Trtllm] {
668            let config = EngineConfig::for_backend(backend);
669            assert_eq!(config.backend, backend);
670            assert_eq!(config.block_size, backend.default_block_size());
671        }
672    }
673
674    #[test]
675    fn deserialization_preserves_an_explicit_block_size() {
676        let config: EngineConfig = serde_json::from_value(serde_json::json!({
677            "backend": "sglang",
678            "block_size": 17
679        }))
680        .unwrap();
681        assert_eq!(config.block_size, 17);
682    }
683
684    #[test]
685    fn legacy_kv_bytes_per_token_deserializes_to_transfer_geometry() {
686        let config: EngineConfig = serde_json::from_value(serde_json::json!({
687            "kv_bytes_per_token": 131_072
688        }))
689        .unwrap();
690        assert_eq!(config.kv_transfer_bytes_per_token, Some(131_072));
691
692        let encoded = serde_json::to_value(config).unwrap();
693        assert_eq!(encoded["kv_transfer_bytes_per_token"], 131_072);
694        assert!(encoded.get("kv_bytes_per_token").is_none());
695    }
696
697    #[test]
698    fn transfer_geometry_rejects_duplicate_new_and_legacy_keys() {
699        let error = serde_json::from_value::<EngineConfig>(serde_json::json!({
700            "kv_transfer_bytes_per_token": 131_072,
701            "kv_bytes_per_token": 65_536
702        }))
703        .unwrap_err();
704        assert!(error.to_string().contains("duplicate field"));
705    }
706
707    #[test]
708    fn deserialization_still_rejects_unknown_fields() {
709        let error = serde_json::from_value::<EngineConfig>(serde_json::json!({
710            "backend": "vllm",
711            "unknown": true
712        }))
713        .unwrap_err();
714        assert!(error.to_string().contains("unknown field"));
715    }
716
717    #[test]
718    fn native_host_offload_deserializes_with_default_bandwidths() {
719        assert_eq!(DEFAULT_HOST_OFFLOAD_BANDWIDTH_GBPS, 32.0);
720        assert_eq!(
721            NativeHostOffloadConfig::new(1),
722            NativeHostOffloadConfig {
723                num_host_blocks: 1,
724                d2h_bandwidth_gbps: 32.0,
725                h2d_bandwidth_gbps: 32.0,
726            }
727        );
728        let config: EngineConfig = serde_json::from_value(serde_json::json!({
729            "backend": "vllm",
730            "block_size": 16,
731            "kv_cache_bytes_per_token": 131_072,
732            "native_host_offload": {
733                "num_host_blocks": 4_096
734            }
735        }))
736        .unwrap();
737
738        assert_eq!(
739            config.native_host_offload,
740            Some(NativeHostOffloadConfig {
741                num_host_blocks: 4_096,
742                d2h_bandwidth_gbps: DEFAULT_HOST_OFFLOAD_BANDWIDTH_GBPS,
743                h2d_bandwidth_gbps: DEFAULT_HOST_OFFLOAD_BANDWIDTH_GBPS,
744            })
745        );
746        config.validate().unwrap();
747
748        let decoded: EngineConfig =
749            serde_json::from_value(serde_json::to_value(&config).unwrap()).unwrap();
750        assert_eq!(decoded, config);
751    }
752
753    #[test]
754    fn native_host_offload_rejects_missing_or_unknown_fields() {
755        let missing_capacity = serde_json::from_value::<EngineConfig>(serde_json::json!({
756            "native_host_offload": {}
757        }))
758        .unwrap_err();
759        assert!(missing_capacity.to_string().contains("num_host_blocks"));
760
761        let unknown = serde_json::from_value::<EngineConfig>(serde_json::json!({
762            "native_host_offload": {
763                "num_host_blocks": 4_096,
764                "policy": "custom"
765            }
766        }))
767        .unwrap_err();
768        assert!(unknown.to_string().contains("unknown field"));
769    }
770
771    #[test]
772    fn native_host_offload_validates_physical_controls() {
773        let cases: &[InvalidHostConfigCase] = &[
774            (
775                |config| {
776                    config.native_host_offload.as_mut().unwrap().num_host_blocks = 0;
777                },
778                "num_host_blocks",
779            ),
780            (
781                |config| {
782                    config
783                        .native_host_offload
784                        .as_mut()
785                        .unwrap()
786                        .d2h_bandwidth_gbps = f64::NAN;
787                },
788                "d2h_bandwidth_gbps",
789            ),
790            (
791                |config| {
792                    config
793                        .native_host_offload
794                        .as_mut()
795                        .unwrap()
796                        .h2d_bandwidth_gbps = -1.0;
797                },
798                "h2d_bandwidth_gbps",
799            ),
800            (
801                |config| {
802                    config.block_size = usize::MAX;
803                    config.kv_cache_bytes_per_token = Some(2);
804                },
805                "positive, representable block size",
806            ),
807            (
808                |config| config.kv_cache_bytes_per_token = None,
809                "requires kv_cache_bytes_per_token",
810            ),
811            (
812                |config| {
813                    config.native_host_offload.as_mut().unwrap().num_host_blocks = usize::MAX;
814                },
815                "capacity in bytes overflowed",
816            ),
817            (
818                |config| {
819                    config
820                        .native_host_offload
821                        .as_mut()
822                        .unwrap()
823                        .d2h_bandwidth_gbps = f64::MIN_POSITIVE;
824                },
825                "unrepresentable transfer duration",
826            ),
827        ];
828        for &(mutate, expected) in cases {
829            assert_invalid_host_config(mutate, expected);
830        }
831    }
832
833    #[test]
834    fn native_host_offload_rejects_unsupported_scheduler_modes() {
835        let cases: &[InvalidHostConfigCase] = &[
836            (|config| config.backend = Backend::Sglang, "backend=vllm"),
837            (
838                |config| config.worker_type = WorkerType::Prefill,
839                "worker_type=aggregated",
840            ),
841            (
842                |config| config.enable_prefix_caching = false,
843                "enable_prefix_caching=true",
844            ),
845            (
846                |config| config.aic_nextn = Some(1),
847                "does not support aic_nextn",
848            ),
849        ];
850        for &(mutate, expected) in cases {
851            assert_invalid_host_config(mutate, expected);
852        }
853    }
854
855    #[test]
856    fn serialization_round_trip_preserves_runtime_neutral_controls() {
857        let config = EngineConfig {
858            backend: Backend::Sglang,
859            block_size: 8,
860            num_gpu_blocks: 123,
861            max_num_seqs: 7,
862            max_num_batched_tokens: 456,
863            worker_type: WorkerType::Decode,
864            preemption_mode: PreemptionMode::Fifo,
865            emit_kv_events: true,
866            emit_kv_token_ids: true,
867            timing_model: TimingModelConfig::Fixed {
868                prefill_ms: 2.5,
869                decode_ms: 0.75,
870            },
871            ..EngineConfig::for_backend(Backend::Sglang)
872        };
873        let encoded = serde_json::to_value(&config).unwrap();
874        let decoded: EngineConfig = serde_json::from_value(encoded).unwrap();
875        assert_eq!(decoded, config);
876    }
877
878    #[test]
879    fn validation_rejects_zero_or_backend_invalid_capacity_fields() {
880        let config = EngineConfig {
881            num_gpu_blocks: 0,
882            ..EngineConfig::default()
883        };
884        assert!(
885            config
886                .validate()
887                .unwrap_err()
888                .to_string()
889                .contains("num_gpu_blocks")
890        );
891
892        let config = EngineConfig {
893            block_size: 1,
894            ..EngineConfig::default()
895        };
896        assert!(
897            config
898                .validate()
899                .unwrap_err()
900                .to_string()
901                .contains("at least two")
902        );
903
904        let config = EngineConfig {
905            max_model_len: Some(0),
906            ..EngineConfig::default()
907        };
908        assert!(
909            config
910                .validate()
911                .unwrap_err()
912                .to_string()
913                .contains("max_model_len")
914        );
915    }
916
917    #[test]
918    fn validation_accepts_sglang_page_size_one_and_rejects_invalid_controls() {
919        let mut config = EngineConfig::for_backend(Backend::Sglang);
920        config.validate().unwrap();
921
922        config.sglang.chunked_prefill_size = 0;
923        assert!(
924            config
925                .validate()
926                .unwrap_err()
927                .to_string()
928                .contains("chunked_prefill_size")
929        );
930
931        let mut config = EngineConfig::for_backend(Backend::Sglang);
932        config.sglang.schedule_conservativeness = f64::NAN;
933        assert!(
934            config
935                .validate()
936                .unwrap_err()
937                .to_string()
938                .contains("schedule_conservativeness")
939        );
940    }
941
942    #[test]
943    fn sglang_supports_disabled_prefix_caching() {
944        let config = EngineConfig {
945            enable_prefix_caching: false,
946            ..EngineConfig::for_backend(Backend::Sglang)
947        };
948        config.validate().unwrap();
949        crate::engine::EngineFactory::new(config).unwrap();
950    }
951
952    #[test]
953    fn sglang_rejects_remaining_unsupported_controls_at_validation_and_factory_boundaries() {
954        let cases = [
955            ("emit_kv_token_ids", true, true, true),
956            ("enable_chunked_prefill", false, true, false),
957        ];
958
959        for (field, emit_kv_token_ids, enable_prefix_caching, enable_chunked_prefill) in cases {
960            let config = EngineConfig {
961                emit_kv_events: emit_kv_token_ids,
962                emit_kv_token_ids,
963                enable_prefix_caching,
964                enable_chunked_prefill,
965                ..EngineConfig::for_backend(Backend::Sglang)
966            };
967            assert!(config.validate().unwrap_err().to_string().contains(field));
968            let error = match crate::engine::EngineFactory::new(config) {
969                Ok(_) => panic!("expected EngineFactory to reject {field}"),
970                Err(error) => error,
971            };
972            assert!(error.to_string().contains(field));
973        }
974    }
975
976    #[test]
977    fn max_model_len_is_vllm_only() {
978        for backend in [Backend::Sglang, Backend::Trtllm] {
979            let mut config = EngineConfig::for_backend(backend);
980            config.max_model_len = Some(128);
981            assert!(
982                config
983                    .validate()
984                    .unwrap_err()
985                    .to_string()
986                    .contains("backend=vllm")
987            );
988        }
989    }
990
991    #[test]
992    fn mtp_configuration_validates_rates_and_decode_scaling() {
993        let mut config = EngineConfig {
994            aic_nextn: Some(2),
995            aic_nextn_accept_rates: Some("0.8,0.5".to_string()),
996            ..EngineConfig::default()
997        };
998        config.validate().unwrap();
999
1000        config.aic_nextn_accept_rates = Some("1.2".to_string());
1001        assert!(config.validate().is_err());
1002
1003        config.aic_nextn_accept_rates = Some("0.8,0.5".to_string());
1004        config.decode_speedup_ratio = 2.0;
1005        assert!(
1006            config
1007                .validate()
1008                .unwrap_err()
1009                .to_string()
1010                .contains("decode_speedup_ratio=1.0")
1011        );
1012    }
1013
1014    #[test]
1015    fn mtp_rates_require_mtp_to_be_enabled() {
1016        let config = EngineConfig {
1017            aic_nextn_accept_rates: Some("0.5".to_string()),
1018            ..EngineConfig::default()
1019        };
1020        assert!(
1021            config
1022                .validate()
1023                .unwrap_err()
1024                .to_string()
1025                .contains("requires aic_nextn")
1026        );
1027    }
1028
1029    #[test]
1030    fn kv_token_ids_require_kv_event_emission() {
1031        let config = EngineConfig {
1032            emit_kv_token_ids: true,
1033            emit_kv_events: false,
1034            ..EngineConfig::default()
1035        };
1036        assert!(
1037            config
1038                .validate()
1039                .unwrap_err()
1040                .to_string()
1041                .contains("emit_kv_token_ids")
1042        );
1043    }
1044
1045    #[test]
1046    fn timing_provider_descriptors_are_validated_without_loading_them() {
1047        let config = EngineConfig {
1048            timing_model: TimingModelConfig::External {
1049                provider: " ".to_string(),
1050                config: serde_json::Value::Null,
1051            },
1052            ..EngineConfig::default()
1053        };
1054        assert!(
1055            config
1056                .validate()
1057                .unwrap_err()
1058                .to_string()
1059                .contains("provider cannot be empty")
1060        );
1061
1062        let config = EngineConfig {
1063            timing_model: TimingModelConfig::Fixed {
1064                prefill_ms: f64::NAN,
1065                decode_ms: 1.0,
1066            },
1067            ..EngineConfig::default()
1068        };
1069        assert!(config.validate().is_err());
1070    }
1071}