Skip to main content

aiconfigurator_core/engine/
spec.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `EngineSpec`: the serializable engine wire format.
5//!
6//! `EngineSpec` is what Python's `compile_engine` emits and what the
7//! Rust `Engine` consumes. It bundles the engine identity
8//! ([`EngineConfig`], needed later to load the matching [`PerfDatabase`]) with
9//! the precompiled context / generation op lists. Each op is an
10//! [`OpSpec`] — a public alias for the crate's [`Op`] enum — and the lists
11//! round-trip through bincode, including the recursive `Overlap` / `Fallback`
12//! children.
13//!
14//! ## Vision is never on the wire
15//!
16//! [`Op::Vision`] derives serde with every other variant (it remains part of
17//! the shared session path), but a compiled `EngineSpec` never
18//! contains a `Vision` op: `compile_engine` decomposes the vision encoder
19//! into its child `Gemm` / `EncoderAttention` / `Elementwise` ops, each an
20//! existing variant. The type round-trips soundly (see the test below); the
21//! constraint is purely a producer-side rule, not enforced by the enum.
22//!
23//! [`PerfDatabase`]: crate::perf_database::PerfDatabase
24
25use serde::{Deserialize, Serialize};
26
27use crate::common::error::AicError;
28use crate::{EngineConfig, ENGINE_SPEC_SCHEMA_VERSION};
29
30/// Public name for the serializable op. Aliases the crate's [`Op`] enum so
31/// the "OpSpec" surface exists without duplicating the definition.
32pub use crate::operators::op::Op as OpSpec;
33
34/// Serializable compiled engine.
35///
36/// `schema_version` guards forward/backward compatibility; `engine` carries
37/// the identity used to load the matching perf database; `context_ops` and
38/// `generation_ops` are the precompiled op lists the runner iterates.
39///
40/// `EngineSpec` derives serde so JSON / other self-describing formats work
41/// directly. The **bincode** wire format, however, must go through
42/// [`EngineSpec::to_bincode`] / [`EngineSpec::from_bincode`], NOT
43/// `bincode::serialize(&spec)` directly: [`EngineConfig`] uses
44/// `#[serde(flatten)]` (load-bearing for the flat ctypes FFI contract), and
45/// bincode 1.x cannot serialize a flattened struct (it emits a map of unknown
46/// length → `SequenceMustHaveLength`). The helpers sidestep this by
47/// JSON-encoding the `engine` field inside the bincode payload, keeping
48/// `EngineConfig` the single source of truth (no mirror struct, no drift).
49#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
50pub struct EngineSpec {
51    pub schema_version: u32,
52    /// Engine identity (model / system / backend / parallelism / quant).
53    /// Needed to locate and load the `PerfDatabase`.
54    pub engine: EngineConfig,
55    /// Context-phase ops, in execution order. Never contains `OpSpec::Vision`
56    /// (decomposed into child ops at compile time).
57    pub context_ops: Vec<OpSpec>,
58    /// Generation-phase ops, in execution order.
59    pub generation_ops: Vec<OpSpec>,
60}
61
62/// Private bincode payload. `engine` is carried as a JSON string so the
63/// `#[serde(flatten)]` on [`EngineConfig`] never reaches the bincode
64/// serializer (which rejects unknown-length maps). The op lists are plain
65/// `Vec<OpSpec>` (no flatten) and bincode-serialize directly.
66#[derive(Serialize, Deserialize)]
67struct BincodeWire {
68    schema_version: u32,
69    engine_json: String,
70    context_ops: Vec<OpSpec>,
71    generation_ops: Vec<OpSpec>,
72}
73
74impl EngineSpec {
75    /// Build a spec, stamping the current [`ENGINE_SPEC_SCHEMA_VERSION`].
76    pub fn new(
77        engine: EngineConfig,
78        context_ops: Vec<OpSpec>,
79        generation_ops: Vec<OpSpec>,
80    ) -> Self {
81        Self {
82            schema_version: ENGINE_SPEC_SCHEMA_VERSION,
83            engine,
84            context_ops,
85            generation_ops,
86        }
87    }
88
89    /// Serialize to the bincode wire format. The `engine` field is
90    /// JSON-encoded inside the payload (see the struct docs) so bincode never
91    /// sees `EngineConfig`'s flattened layout.
92    pub fn to_bincode(&self) -> Result<Vec<u8>, AicError> {
93        let engine_json = serde_json::to_string(&self.engine)
94            .map_err(|e| AicError::EngineSpec(format!("engine JSON encode: {e}")))?;
95        let wire = BincodeWire {
96            schema_version: self.schema_version,
97            engine_json,
98            context_ops: self.context_ops.clone(),
99            generation_ops: self.generation_ops.clone(),
100        };
101        bincode::serialize(&wire).map_err(|e| AicError::EngineSpec(format!("bincode encode: {e}")))
102    }
103
104    /// Deserialize from the bincode wire format produced by [`Self::to_bincode`].
105    ///
106    /// The `schema_version` prefix is read and validated **before** the
107    /// variable-layout op payloads are decoded. bincode is not self-describing,
108    /// so a producer/consumer op-layout skew (e.g. a newer producer that added
109    /// serialized fields to an `OpSpec`) would otherwise fail deep inside the
110    /// payload with a generic `bincode decode: io error`, masking the real
111    /// cause. Reading the leading version first lets a version mismatch surface
112    /// as a clear [`AicError::UnsupportedSchemaVersion`] instead.
113    pub fn from_bincode(bytes: &[u8]) -> Result<Self, AicError> {
114        // `schema_version` is the first field of `BincodeWire`, so it is the
115        // first value in the byte stream. Decode just it and gate on it before
116        // touching the op lists (which is where a layout skew would fail).
117        let mut cursor = std::io::Cursor::new(bytes);
118        let schema_version: u32 = bincode::deserialize_from(&mut cursor).map_err(|e| {
119            AicError::EngineSpec(format!(
120                "bincode decode of the leading schema_version prefix failed \
121                 (payload is {} bytes; too short or not an EngineSpec wire buffer): {e}",
122                bytes.len()
123            ))
124        })?;
125        if schema_version != ENGINE_SPEC_SCHEMA_VERSION {
126            return Err(AicError::UnsupportedSchemaVersion {
127                kind: "EngineSpec",
128                got: schema_version,
129                expected: ENGINE_SPEC_SCHEMA_VERSION,
130            });
131        }
132        let wire: BincodeWire = bincode::deserialize(bytes).map_err(|e| {
133            AicError::EngineSpec(format!(
134                "bincode decode of the op payloads failed at matching \
135                 schema_version {schema_version} — this indicates op-layout drift \
136                 within the same version (an OpSpec changed without a \
137                 ENGINE_SPEC_SCHEMA_VERSION bump) or a corrupt payload, not a \
138                 version skew: {e}"
139            ))
140        })?;
141        let engine: EngineConfig = serde_json::from_str(&wire.engine_json).map_err(|e| {
142            AicError::EngineSpec(format!(
143                "engine JSON decode failed at schema_version {schema_version}: {e}"
144            ))
145        })?;
146        Ok(Self {
147            schema_version: wire.schema_version,
148            engine,
149            context_ops: wire.context_ops,
150            generation_ops: wire.generation_ops,
151        })
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use std::collections::BTreeMap;
159
160    use crate::common::enums::{
161        BackendKind, CommQuantMode, FmhaQuantMode, GemmQuantMode, KvCacheQuantMode, MoeQuantMode,
162    };
163    use crate::operators::moe_dispatch::DispatchFlavor;
164    use crate::operators::op::{FallbackOp, OverlapOp};
165    use crate::operators::{
166        ContextAttentionOp, ContextMlaOp, CustomAllReduceOp, DsaModuleOp, Dsv4MegaMoeOp,
167        Dsv4ModuleOp, ElementwiseOp, EmbeddingOp, EncoderAttentionOp, GdnOp, GemmOp,
168        GenerationAttentionOp, GenerationMlaOp, KdaOp, Mamba2Op, MhcModuleOp, MlaBmmOp,
169        MlaModuleOp, MoEDispatchOp, MoeAllToAllOp, MoeExpertComputeOp, MoeOp, NcclOp, P2POp,
170        VisionEncoderOp, WideEpContextMlaOp, WideEpGenerationMlaOp,
171    };
172    use crate::perf_database::dsv4::AttnKind;
173    use crate::{
174        DataType, ParallelMapping, QuantizationConfig, SpeculativeConfig,
175        ENGINE_CONFIG_SCHEMA_VERSION,
176    };
177
178    // ---- Representative-value builders for each Op variant ----
179
180    fn gemm() -> GemmOp {
181        GemmOp {
182            name: "qkv_gemm".into(),
183            scale_factor: 2.0,
184            n: 4096,
185            k: 4096,
186            quant_mode: GemmQuantMode::Fp8,
187            scale_num_tokens: 0,
188            low_precision_input: true,
189            seq_split: 1,
190            below_grid_sol: false,
191        }
192    }
193
194    fn embedding() -> EmbeddingOp {
195        EmbeddingOp {
196            name: "embedding".into(),
197            scale_factor: 1.0,
198            vocab_size: 128_256,
199            hidden_size: 4096,
200            quant_mode: GemmQuantMode::Bfloat16,
201            seq_split: 1,
202        }
203    }
204
205    fn elementwise() -> ElementwiseOp {
206        ElementwiseOp {
207            name: "rmsnorm".into(),
208            scale_factor: 1.5,
209            bytes_per_token: 8192.0,
210            scale_num_tokens: 1,
211            seq_split: 1,
212        }
213    }
214
215    fn context_attention() -> ContextAttentionOp {
216        ContextAttentionOp {
217            name: "context_attention".into(),
218            scale_factor: 1.0,
219            n: 32,
220            n_kv: 8,
221            head_size: 128,
222            window_size: 0,
223            kv_cache_dtype: KvCacheQuantMode::Fp8,
224            fmha_quant_mode: FmhaQuantMode::Bfloat16,
225            use_qk_norm: true,
226            cp_size: 1,
227            // Multi-entry so the round-trip proves the whole Vec<String>
228            // survives, not just a single-element degenerate case.
229            lane_order: vec!["trtllm_mha".into(), "flashinfer".into(), "default".into()],
230        }
231    }
232
233    fn generation_attention() -> GenerationAttentionOp {
234        GenerationAttentionOp {
235            name: "generation_attention".into(),
236            scale_factor: 1.0,
237            n: 32,
238            n_kv: 8,
239            head_size: 128,
240            window_size: 4096,
241            kv_cache_dtype: KvCacheQuantMode::Int8,
242            lane_order: vec!["triton".into(), "trtllm_mha".into(), "default".into()],
243        }
244    }
245
246    fn encoder_attention() -> EncoderAttentionOp {
247        EncoderAttentionOp {
248            name: "encoder_attention".into(),
249            scale_factor: 1.0,
250            n: 16,
251            head_size: 80,
252            fmha_quant_mode: FmhaQuantMode::Fp8,
253            partial_rotary_factor: 0.0,
254        }
255    }
256
257    fn context_mla() -> ContextMlaOp {
258        ContextMlaOp {
259            name: "context_mla".into(),
260            scale_factor: 1.0,
261            num_heads: 128,
262            kv_cache_dtype: KvCacheQuantMode::Bfloat16,
263            fmha_quant_mode: FmhaQuantMode::Bfloat16,
264            cp_size: 1,
265        }
266    }
267
268    fn generation_mla() -> GenerationMlaOp {
269        GenerationMlaOp {
270            name: "generation_mla".into(),
271            scale_factor: 1.0,
272            num_heads: 128,
273            kv_cache_dtype: KvCacheQuantMode::Fp8,
274        }
275    }
276
277    fn mla_module() -> MlaModuleOp {
278        MlaModuleOp {
279            name: "context_mla_module".into(),
280            scale_factor: 1.0,
281            num_heads: 128,
282            kv_cache_dtype: KvCacheQuantMode::Fp8,
283            fmha_quant_mode: FmhaQuantMode::Fp8,
284            gemm_quant_mode: GemmQuantMode::Fp8Block,
285            native_num_heads: Some(128),
286        }
287    }
288
289    fn mla_bmm() -> MlaBmmOp {
290        MlaBmmOp {
291            name: "mla_bmm_pre".into(),
292            scale_factor: 1.0,
293            num_heads: 128,
294            quant_mode: GemmQuantMode::Bfloat16,
295            is_pre: true,
296        }
297    }
298
299    fn moe() -> MoeOp {
300        MoeOp {
301            name: "moe".into(),
302            scale_factor: 1.0,
303            hidden_size: 7168,
304            inter_size: 2048,
305            topk: 8,
306            num_experts: 256,
307            moe_tp_size: 1,
308            moe_ep_size: 8,
309            attention_dp_size: 1,
310            quant_mode: MoeQuantMode::Fp8Block,
311            workload_distribution: "power_law_1.2".into(),
312            is_gated: true,
313            moe_backend: None,
314            enable_eplb: false,
315            is_context: false,
316        }
317    }
318
319    fn moe_dispatch() -> MoEDispatchOp {
320        MoEDispatchOp {
321            name: "moe_dispatch".into(),
322            scale_factor: 1.0,
323            hidden_size: 7168,
324            topk: 8,
325            num_experts: 256,
326            moe_tp_size: 1,
327            moe_ep_size: 8,
328            attention_dp_size: 8,
329            pre_dispatch: true,
330            backend: BackendKind::Trtllm,
331            flavor: DispatchFlavor::TrtllmAlltoall,
332            comm_quant: CommQuantMode::Half,
333            moe_quant: MoeQuantMode::Fp8Block,
334            attn_cp_size: 1,
335            is_context: false,
336            sms: 12,
337            scale_num_tokens: 1,
338            attn_ar_modeled: false,
339        }
340    }
341
342    fn custom_all_reduce() -> CustomAllReduceOp {
343        CustomAllReduceOp {
344            name: "custom_all_reduce".into(),
345            scale_factor: 1.0,
346            hidden_size: 4096,
347            tp_size: 8,
348            quant: CommQuantMode::Half,
349            seq_split: 1,
350        }
351    }
352
353    fn nccl() -> NcclOp {
354        NcclOp {
355            name: "nccl_all_reduce".into(),
356            scale_factor: 1.0,
357            hidden_size: 4096.0,
358            num_gpus: 8,
359            dtype: CommQuantMode::Half,
360            operation: "all_reduce".into(),
361            seq_split: 1,
362        }
363    }
364
365    fn p2p() -> P2POp {
366        P2POp {
367            name: "p2p".into(),
368            scale_factor: 1.0,
369            pp_size: 4,
370            hidden_size: 4096,
371            seq_split: 1,
372        }
373    }
374
375    fn vision() -> VisionEncoderOp {
376        VisionEncoderOp {
377            name: "vision_encoder".into(),
378            scale_factor: 1.0,
379            num_layers: 24,
380            num_heads: 16,
381            head_size: 80,
382            hidden_size: 1280,
383            intermediate_size: 5120,
384            fmha_quant: FmhaQuantMode::Bfloat16,
385            gemm_quant: GemmQuantMode::Bfloat16,
386        }
387    }
388
389    fn dsa_module() -> DsaModuleOp {
390        DsaModuleOp {
391            name: "dsa_module".into(),
392            scale_factor: 1.0,
393            num_heads: 128,
394            kv_cache_dtype: KvCacheQuantMode::Fp8,
395            fmha_quant_mode: FmhaQuantMode::Fp8,
396            gemm_quant_mode: GemmQuantMode::Fp8Block,
397            architecture: "DeepseekV32ForCausalLM".into(),
398            index_topk: 2048,
399            cp_size: 1,
400            full_frac: 1.0,
401            attn_projection_quant_modes: None,
402        }
403    }
404
405    fn msa_module() -> crate::operators::MsaModuleOp {
406        crate::operators::MsaModuleOp {
407            name: "context_attention".into(),
408            scale_factor: 62.0,
409            num_heads: 8,
410            num_kv_heads: 1,
411            hidden_size: 7168,
412            head_dim: 128,
413            v_head_dim: 128,
414            index_n_heads: 64,
415            index_head_dim: 128,
416            index_topk: 2048,
417            block_size: 64,
418            kv_cache_dtype: KvCacheQuantMode::Bfloat16,
419            fmha_quant_mode: FmhaQuantMode::Bfloat16,
420            gemm_quant_mode: GemmQuantMode::Fp8Block,
421            dsa_architecture: "GlmMoeDsaForCausalLM".into(),
422            dsa_scale_k: 1.0,
423        }
424    }
425
426    fn dsv4_module() -> Dsv4ModuleOp {
427        Dsv4ModuleOp {
428            name: "dsv4_module".into(),
429            scale_factor: 1.0,
430            attn_kind: AttnKind::Hca,
431            num_heads: 128,
432            native_heads: 128,
433            tp_size: 1,
434            kv_cache_dtype: KvCacheQuantMode::Fp8,
435            fmha_quant_mode: FmhaQuantMode::Fp8,
436            gemm_quant_mode: GemmQuantMode::Fp8Block,
437            architecture: "DeepseekV4ForCausalLM".into(),
438            cp_size: 1,
439            window_size: None,
440            hidden_size: 7168,
441            q_lora_rank: 1536,
442            o_lora_rank: 1024,
443            head_dim: 512,
444            rope_head_dim: 64,
445            index_n_heads: 64,
446            index_head_dim: 128,
447            index_topk: 1024,
448            o_groups: Some(16),
449        }
450    }
451
452    fn dsv4_megamoe() -> Dsv4MegaMoeOp {
453        Dsv4MegaMoeOp {
454            name: "context_megamoe".into(),
455            scale_factor: 61.0,
456            hidden_size: 7168,
457            inter_size: 3072,
458            topk: 6,
459            num_experts: 384,
460            moe_tp_size: 1,
461            moe_ep_size: 8,
462            quant_mode: MoeQuantMode::W4a8Mxfp4Mxfp8,
463            workload_distribution: "balanced".into(),
464            is_context: true,
465            source_policy: "random".into(),
466            pre_dispatch: "sglang_jit".into(),
467            num_fused_shared_experts: 0,
468            kernel_source: "deepgemm_megamoe".into(),
469            kernel_dtype: "fp8_fp4".into(),
470        }
471    }
472
473    fn mhc() -> MhcModuleOp {
474        MhcModuleOp {
475            name: "mhc_module".into(),
476            scale_factor: 1.0,
477            op: "pre".into(),
478            hc_mult: 4,
479            hidden_size: 7168,
480            architecture: "DeepseekV4ForCausalLM".into(),
481            sinkhorn_iters: 20,
482            quant_mode: GemmQuantMode::Bfloat16,
483            seq_split: 1,
484        }
485    }
486
487    fn mamba2() -> Mamba2Op {
488        Mamba2Op {
489            name: "mamba2".into(),
490            scale_factor: 1.0,
491            kernel_source: "mamba_chunk_scan".into(),
492            phase: "context".into(),
493            d_model: 4096,
494            d_state: 128,
495            d_conv: 4,
496            nheads: 128,
497            head_dim: 64,
498            n_groups: 8,
499            chunk_size: 256,
500        }
501    }
502
503    fn gdn() -> GdnOp {
504        GdnOp {
505            name: "gdn".into(),
506            scale_factor: 1.0,
507            kernel_source: "gdn_kernel".into(),
508            phase: "generation".into(),
509            d_model: 4096,
510            d_conv: 4,
511            num_k_heads: 16,
512            head_k_dim: 128,
513            num_v_heads: 32,
514            head_v_dim: 128,
515            // Non-default value so the round-trip notices a
516            // `#[serde(default)]` swallowing the carried field.
517            mamba_ssm_dtype: "bfloat16".into(),
518        }
519    }
520
521    fn kda() -> KdaOp {
522        KdaOp {
523            name: "kda".into(),
524            scale_factor: 1.0,
525            kernel_source: "fused_sigmoid_gating_delta_rule_update".into(),
526            phase: "verify".into(),
527            d_model: 7168,
528            d_conv: 4,
529            num_k_heads: 16,
530            head_k_dim: 128,
531            num_v_heads: 16,
532            head_v_dim: 128,
533            draft_tokens: 4,
534        }
535    }
536
537    fn wideep_context_mla() -> WideEpContextMlaOp {
538        WideEpContextMlaOp {
539            name: "wideep_context_mla".into(),
540            scale_factor: 1.0,
541            num_heads: 128,
542            kv_cache_dtype: KvCacheQuantMode::Fp8,
543            fmha_quant_mode: FmhaQuantMode::Fp8,
544            attn_backend: "flashinfer".into(),
545            cp_size: 1,
546        }
547    }
548
549    fn wideep_generation_mla() -> WideEpGenerationMlaOp {
550        WideEpGenerationMlaOp {
551            name: "wideep_generation_mla".into(),
552            scale_factor: 1.0,
553            num_heads: 128,
554            kv_cache_dtype: KvCacheQuantMode::Fp8,
555            fmha_quant_mode: FmhaQuantMode::Fp8,
556            attn_backend: "flashinfer".into(),
557        }
558    }
559
560    /// Large-EP comm phase with every optional field set to a NON-default
561    /// value, so the round-trip would notice a `#[serde(default)]` swallowing
562    /// a carried field.
563    fn moe_all_to_all() -> MoeAllToAllOp {
564        MoeAllToAllOp {
565            name: "moe_dispatch".into(),
566            scale_factor: 61.0,
567            phase: "dispatch".into(),
568            comm_backend: "deepep_ht".into(),
569            comm_dtype: "fp8_block".into(),
570            hidden_size: 7168,
571            topk: 8,
572            num_experts: 256,
573            moe_ep_size: 16,
574            node_num: 2,
575            sms: 24,
576            attention_tp_size: 2,
577        }
578    }
579
580    /// Large-EP expert compute. `num_slots` / `kernel_source` are `Some(...)`
581    /// here on purpose — the `None` (Python-default) case is what production
582    /// emits, and both encodings must survive the wire.
583    fn moe_expert_compute() -> MoeExpertComputeOp {
584        MoeExpertComputeOp {
585            name: "moe".into(),
586            scale_factor: 61.0,
587            hidden_size: 7168,
588            inter_size: 2048,
589            topk: 8,
590            num_experts: 256,
591            moe_ep_size: 16,
592            quant_mode: MoeQuantMode::Fp8Block,
593            workload_distribution: "power_law_1.2".into(),
594            attention_dp_size: 8,
595            inference_phase: "context".into(),
596            num_slots: Some(288),
597            kernel_source: Some("deepep_moe".into()),
598            is_gated: true,
599            enable_eplb: true,
600        }
601    }
602
603    fn overlap() -> OverlapOp {
604        // Recursive: nested children on both groups.
605        OverlapOp {
606            name: "overlap_attn_moe".into(),
607            group_a: vec![OpSpec::ContextMla(context_mla()), OpSpec::Gemm(gemm())],
608            group_b: vec![OpSpec::Moe(moe()), OpSpec::MoeDispatch(moe_dispatch())],
609        }
610    }
611
612    fn fpm_forward() -> crate::operators::FpmForwardOp {
613        // Recursive like Overlap/Fallback: sol_ops carries the model's
614        // original granular list, so the round-trip must preserve nesting.
615        crate::operators::FpmForwardOp {
616            name: "fpm_forward_prefill".into(),
617            phase: crate::operators::FpmPhase::Prefill,
618            model_path: "org/model-a".into(),
619            match_identity: vec![
620                "nvfp4".into(),
621                "nvfp4".into(),
622                "bfloat16".into(),
623                "half".into(),
624                "fp8".into(),
625                "4".into(),
626                "1".into(),
627                "1".into(),
628                "4".into(),
629                "1".into(),
630                "1".into(),
631            ],
632            weight_bytes: 1.5e10,
633            sol_ops: vec![
634                OpSpec::Gemm(gemm()),
635                OpSpec::ContextAttention(context_attention()),
636            ],
637        }
638    }
639
640    fn fallback() -> FallbackOp {
641        // Recursive: a primary module op with a granular per-kernel fallback
642        // chain that itself contains a nested Overlap.
643        FallbackOp {
644            name: "mla_fallback".into(),
645            primary: Box::new(OpSpec::MlaModuleContext(mla_module())),
646            fallback: vec![
647                OpSpec::Gemm(gemm()),
648                OpSpec::ContextMla(context_mla()),
649                OpSpec::Overlap(overlap()),
650            ],
651        }
652    }
653
654    /// Every `Op` variant, constructed once. The exhaustive `match` below the
655    /// `Vec` build forces the compiler to flag any newly added variant that
656    /// this round-trip suite forgot to cover.
657    fn all_op_variants() -> Vec<OpSpec> {
658        let ops = vec![
659            OpSpec::Gemm(gemm()),
660            OpSpec::Embedding(embedding()),
661            OpSpec::Elementwise(elementwise()),
662            OpSpec::ContextAttention(context_attention()),
663            OpSpec::GenerationAttention(generation_attention()),
664            OpSpec::EncoderAttention(encoder_attention()),
665            OpSpec::ContextMla(context_mla()),
666            OpSpec::GenerationMla(generation_mla()),
667            OpSpec::MlaModuleContext(mla_module()),
668            OpSpec::MlaModuleGeneration(mla_module()),
669            OpSpec::MlaBmm(mla_bmm()),
670            OpSpec::Moe(moe()),
671            OpSpec::MoeDispatch(moe_dispatch()),
672            OpSpec::CustomAllReduce(custom_all_reduce()),
673            OpSpec::Nccl(nccl()),
674            OpSpec::P2P(p2p()),
675            OpSpec::Vision(vision()),
676            OpSpec::DsaContext(dsa_module()),
677            OpSpec::DsaGeneration(dsa_module()),
678            OpSpec::MsaContext(msa_module()),
679            OpSpec::MsaGeneration(msa_module()),
680            OpSpec::Dsv4Context(dsv4_module()),
681            OpSpec::Dsv4Generation(dsv4_module()),
682            OpSpec::Mhc(mhc()),
683            OpSpec::Mamba2(mamba2()),
684            OpSpec::Gdn(gdn()),
685            OpSpec::WideEpContextMla(wideep_context_mla()),
686            OpSpec::WideEpGenerationMla(wideep_generation_mla()),
687            OpSpec::Overlap(overlap()),
688            OpSpec::Fallback(fallback()),
689            // Appended AFTER Fallback (bincode enum indices are positional;
690            // appending shifts nothing, so no ENGINE_SPEC_SCHEMA_VERSION bump).
691            OpSpec::Dsv4MegaMoe(dsv4_megamoe()),
692            // Appended in wire order: Kda, FpmForward, then this PR's
693            // large-EP pair.
694            OpSpec::Kda(kda()),
695            OpSpec::FpmForward(fpm_forward()),
696            OpSpec::MoeAllToAll(moe_all_to_all()),
697            OpSpec::MoeExpertCompute(moe_expert_compute()),
698        ];
699
700        // Exhaustiveness guard: if a variant is added to `Op`, this match
701        // fails to compile until it is also added to `all_op_variants`.
702        for op in &ops {
703            match op {
704                OpSpec::Gemm(_)
705                | OpSpec::Embedding(_)
706                | OpSpec::Elementwise(_)
707                | OpSpec::ContextAttention(_)
708                | OpSpec::GenerationAttention(_)
709                | OpSpec::EncoderAttention(_)
710                | OpSpec::ContextMla(_)
711                | OpSpec::GenerationMla(_)
712                | OpSpec::MlaModuleContext(_)
713                | OpSpec::MlaModuleGeneration(_)
714                | OpSpec::MlaBmm(_)
715                | OpSpec::Moe(_)
716                | OpSpec::MoeDispatch(_)
717                | OpSpec::CustomAllReduce(_)
718                | OpSpec::Nccl(_)
719                | OpSpec::P2P(_)
720                | OpSpec::Vision(_)
721                | OpSpec::DsaContext(_)
722                | OpSpec::DsaGeneration(_)
723                | OpSpec::MsaContext(_)
724                | OpSpec::MsaGeneration(_)
725                | OpSpec::Dsv4Context(_)
726                | OpSpec::Dsv4Generation(_)
727                | OpSpec::Mhc(_)
728                | OpSpec::Mamba2(_)
729                | OpSpec::Gdn(_)
730                | OpSpec::WideEpContextMla(_)
731                | OpSpec::WideEpGenerationMla(_)
732                | OpSpec::FpmForward(_)
733                | OpSpec::Overlap(_)
734                | OpSpec::Fallback(_)
735                | OpSpec::Dsv4MegaMoe(_)
736                | OpSpec::Kda(_)
737                | OpSpec::MoeAllToAll(_)
738                | OpSpec::MoeExpertCompute(_) => {}
739            }
740        }
741        ops
742    }
743
744    fn sample_engine_config() -> EngineConfig {
745        EngineConfig {
746            schema_version: ENGINE_CONFIG_SCHEMA_VERSION,
747            model_name: "deepseek-ai/DeepSeek-V3".into(),
748            system_name: "h200_sxm".into(),
749            systems_path: None,
750            backend: crate::BackendKind::Trtllm,
751            backend_version: Some("1.0.0rc3".into()),
752            forward_model: None,
753            kv_block_size: Some(64),
754            parallel: ParallelMapping {
755                tp_size: 8,
756                pp_size: 1,
757                attention_dp_size: Some(8),
758                moe_tp_size: Some(1),
759                moe_ep_size: Some(8),
760                cp_size: None,
761            },
762            quantization: QuantizationConfig {
763                weight_dtype: Some(DataType::Fp8),
764                moe_dtype: Some(DataType::Fp8),
765                activation_dtype: Some(DataType::Fp8),
766                kv_cache_dtype: Some(DataType::Fp8),
767            },
768            speculative: Some(SpeculativeConfig { nextn: Some(1) }),
769            enable_shared_layer: None,
770            strict_provenance: false,
771            database_mode: Default::default(),
772            tolerate_dirless_version: false,
773            transfer_policy: None,
774            extra: BTreeMap::new(),
775        }
776    }
777
778    /// Pin the bincode POSITIONAL variant index of the first and the two last
779    /// `Op` variants. bincode encodes an enum as a leading 4-byte LE variant
780    /// index, so inserting or removing a variant mid-enum silently reinterprets
781    /// every later variant on the wire.
782    ///
783    /// These pin bincode positional indices; if this test fails you reordered/
784    /// inserted mid-enum — append instead, or bump ENGINE_SPEC_SCHEMA_VERSION
785    /// in lockstep (config.rs + engine.py).
786    #[test]
787    fn op_variant_indices_are_pinned() {
788        const GEMM_INDEX: u32 = 0;
789        // Re-derived after current main's Kda and FpmForward tail variants,
790        // and after retiring the two mid-enum wideEP MoE variants.
791        const MOE_ALL_TO_ALL_INDEX: u32 = 33;
792        const MOE_EXPERT_COMPUTE_INDEX: u32 = 34;
793
794        let index_of = |op: &OpSpec| -> u32 {
795            let bytes = bincode::serialize(op).expect("serialize op");
796            u32::from_le_bytes(bytes[..4].try_into().expect("4-byte variant index prefix"))
797        };
798
799        assert_eq!(
800            index_of(&OpSpec::Gemm(gemm())),
801            GEMM_INDEX,
802            "first variant moved"
803        );
804        assert_eq!(
805            index_of(&OpSpec::MoeAllToAll(moe_all_to_all())),
806            MOE_ALL_TO_ALL_INDEX,
807            "MoeAllToAll index moved"
808        );
809        assert_eq!(
810            index_of(&OpSpec::MoeExpertCompute(moe_expert_compute())),
811            MOE_EXPERT_COMPUTE_INDEX,
812            "MoeExpertCompute index moved"
813        );
814
815        // The two last variants must stay adjacent and terminal: appending is
816        // the only safe growth direction.
817        assert_eq!(MOE_EXPERT_COMPUTE_INDEX, MOE_ALL_TO_ALL_INDEX + 1);
818        assert_eq!(
819            MOE_EXPERT_COMPUTE_INDEX as usize + 1,
820            all_op_variants().len(),
821            "all_op_variants() must cover exactly the pinned variant count"
822        );
823    }
824
825    #[test]
826    fn every_op_variant_round_trips_through_bincode() {
827        for op in all_op_variants() {
828            let bytes = bincode::serialize(&op).expect("serialize op");
829            let decoded: OpSpec = bincode::deserialize(&bytes).expect("deserialize op");
830            assert_eq!(op, decoded, "round-trip mismatch for {:?}", op);
831        }
832    }
833
834    #[test]
835    fn recursive_overlap_round_trips_with_nested_children() {
836        let op = OpSpec::Overlap(overlap());
837        let bytes = bincode::serialize(&op).unwrap();
838        let decoded: OpSpec = bincode::deserialize(&bytes).unwrap();
839        assert_eq!(op, decoded);
840    }
841
842    #[test]
843    fn recursive_fallback_round_trips_with_nested_children() {
844        let op = OpSpec::Fallback(fallback());
845        let bytes = bincode::serialize(&op).unwrap();
846        let decoded: OpSpec = bincode::deserialize(&bytes).unwrap();
847        assert_eq!(op, decoded);
848    }
849
850    #[test]
851    fn mla_module_none_native_round_trips_followed_by_another_op() {
852        // native_num_heads=None must still be serialized (no skip_serializing_if):
853        // bincode decodes positionally, so an omitted Option would desync the
854        // ops decoded after it (#1458 review).
855        let mut none_native = mla_module();
856        none_native.native_num_heads = None;
857        let spec = EngineSpec::new(
858            sample_engine_config(),
859            vec![OpSpec::MlaModuleContext(none_native), OpSpec::Gemm(gemm())],
860            vec![
861                OpSpec::MlaModuleGeneration(mla_module()),
862                OpSpec::Moe(moe()),
863            ],
864        );
865        let bytes = spec.to_bincode().expect("to_bincode");
866        let decoded = EngineSpec::from_bincode(&bytes).expect("from_bincode");
867        assert_eq!(spec, decoded);
868    }
869
870    #[test]
871    fn engine_spec_round_trips_through_bincode() {
872        let spec = EngineSpec::new(
873            sample_engine_config(),
874            vec![
875                OpSpec::Embedding(embedding()),
876                OpSpec::ContextAttention(context_attention()),
877                OpSpec::Overlap(overlap()),
878                OpSpec::Gemm(gemm()),
879            ],
880            vec![
881                OpSpec::GenerationAttention(generation_attention()),
882                OpSpec::Fallback(fallback()),
883                OpSpec::Moe(moe()),
884            ],
885        );
886
887        assert_eq!(spec.schema_version, ENGINE_SPEC_SCHEMA_VERSION);
888
889        let bytes = spec.to_bincode().expect("to_bincode");
890        let decoded = EngineSpec::from_bincode(&bytes).expect("from_bincode");
891        assert_eq!(spec, decoded);
892    }
893
894    /// A version skew combined with an op-layout change must surface as a clear
895    /// [`AicError::UnsupportedSchemaVersion`], NOT a generic bincode I/O error.
896    ///
897    /// This reproduces the cross-version failure mode: a producer at a different
898    /// schema version emits op payloads whose layout the consumer cannot decode.
899    /// We simulate it by stamping a foreign version into the leading prefix and
900    /// truncating the op payload. `from_bincode` must read + reject the version
901    /// *before* it attempts to decode the (now-undecodable) op lists.
902    #[test]
903    fn version_skew_reports_unsupported_before_payload_decode() {
904        let spec = EngineSpec::new(
905            sample_engine_config(),
906            vec![
907                OpSpec::Gemm(gemm()),
908                OpSpec::ContextAttention(context_attention()),
909            ],
910            vec![OpSpec::GenerationAttention(generation_attention())],
911        );
912        let mut bytes = spec.to_bincode().expect("to_bincode");
913
914        // Overwrite the 4-byte little-endian `schema_version` prefix with a
915        // version this consumer does not speak.
916        let foreign = ENGINE_SPEC_SCHEMA_VERSION + 1;
917        bytes[..4].copy_from_slice(&foreign.to_le_bytes());
918        // Corrupt the op payload so a decode-first implementation fails there
919        // with a generic bincode I/O error instead of reaching the version gate.
920        bytes.truncate(bytes.len() - 8);
921
922        match EngineSpec::from_bincode(&bytes) {
923            Err(AicError::UnsupportedSchemaVersion {
924                kind,
925                got,
926                expected,
927            }) => {
928                assert_eq!(kind, "EngineSpec");
929                assert_eq!(got, foreign);
930                assert_eq!(expected, ENGINE_SPEC_SCHEMA_VERSION);
931            }
932            other => {
933                panic!("expected UnsupportedSchemaVersion before payload decode, got {other:?}")
934            }
935        }
936    }
937
938    /// Canonical valid spec used by the handshake tests below.
939    fn handshake_spec() -> EngineSpec {
940        EngineSpec::new(
941            sample_engine_config(),
942            vec![
943                OpSpec::Gemm(gemm()),
944                OpSpec::ContextAttention(context_attention()),
945            ],
946            vec![OpSpec::GenerationAttention(generation_attention())],
947        )
948    }
949
950    /// Round-trip preserves the stamped schema version end to end.
951    #[test]
952    fn from_bincode_round_trips_and_preserves_version() {
953        let spec = handshake_spec();
954        let decoded = EngineSpec::from_bincode(&spec.to_bincode().expect("to_bincode"))
955            .expect("from_bincode");
956        assert_eq!(decoded, spec);
957        assert_eq!(decoded.schema_version, ENGINE_SPEC_SCHEMA_VERSION);
958    }
959
960    /// A buffer too short to even hold the 4-byte version prefix must fail at the
961    /// prefix stage, not deep in the (absent) payload.
962    #[test]
963    fn from_bincode_rejects_empty_buffer() {
964        match EngineSpec::from_bincode(&[]) {
965            Err(AicError::EngineSpec(msg)) => {
966                assert!(
967                    msg.contains("schema_version prefix"),
968                    "message should name the prefix stage, got: {msg}"
969                );
970            }
971            other => panic!("expected EngineSpec prefix error, got {other:?}"),
972        }
973    }
974
975    /// The version gate fires even when the op payload is fully intact — the
976    /// rejection is driven by the version alone, not by a decode failure.
977    #[test]
978    fn from_bincode_version_gate_fires_with_intact_payload() {
979        let mut bytes = handshake_spec().to_bincode().expect("to_bincode");
980        let foreign = ENGINE_SPEC_SCHEMA_VERSION + 7;
981        bytes[..4].copy_from_slice(&foreign.to_le_bytes()); // only the prefix changes
982
983        match EngineSpec::from_bincode(&bytes) {
984            Err(AicError::UnsupportedSchemaVersion {
985                kind,
986                got,
987                expected,
988            }) => {
989                assert_eq!(kind, "EngineSpec");
990                assert_eq!(got, foreign);
991                assert_eq!(expected, ENGINE_SPEC_SCHEMA_VERSION);
992            }
993            other => panic!("expected UnsupportedSchemaVersion, got {other:?}"),
994        }
995    }
996
997    /// v11 -> v12 regression (PR-6): `DsaModuleOp` gained
998    /// `attn_projection_quant_modes`, a positional bincode layout change. A
999    /// pre-PR v11 producer's DSA payload must be rejected by the VERSION GATE
1000    /// (before any op decoding) as `UnsupportedSchemaVersion` — never reach
1001    /// the op-payload stage where the missing Option tag would surface as an
1002    /// opaque "unexpected end of file".
1003    #[test]
1004    fn from_bincode_rejects_v11_dsa_producer_at_the_version_gate() {
1005        let spec = EngineSpec::new(
1006            sample_engine_config(),
1007            vec![OpSpec::DsaContext(dsa_module())],
1008            vec![],
1009        );
1010        let mut bytes = spec.to_bincode().expect("to_bincode");
1011        bytes[..4].copy_from_slice(&11u32.to_le_bytes()); // a v11 producer's stamp
1012
1013        match EngineSpec::from_bincode(&bytes) {
1014            Err(AicError::UnsupportedSchemaVersion {
1015                kind,
1016                got,
1017                expected,
1018            }) => {
1019                assert_eq!(kind, "EngineSpec");
1020                assert_eq!(got, 11);
1021                assert_eq!(expected, ENGINE_SPEC_SCHEMA_VERSION);
1022            }
1023            other => {
1024                panic!("expected UnsupportedSchemaVersion for a v11 DSA payload, got {other:?}")
1025            }
1026        }
1027    }
1028
1029    /// v13 -> v14 regression (PR #1533): `GdnOp` gained
1030    /// `mamba_ssm_dtype`, a positional bincode layout change. A pre-PR v13
1031    /// producer's GDN payload must be rejected by the version gate before the
1032    /// missing trailing string reaches op decoding as an opaque EOF.
1033    #[test]
1034    fn from_bincode_rejects_v13_gdn_producer_at_the_version_gate() {
1035        let mut legacy_gdn = gdn();
1036        legacy_gdn.mamba_ssm_dtype.clear();
1037        let spec = EngineSpec::new(
1038            sample_engine_config(),
1039            vec![],
1040            vec![OpSpec::Gdn(legacy_gdn)],
1041        );
1042        let mut bytes = spec.to_bincode().expect("to_bincode");
1043
1044        // The GDN op is the final value in the wire payload. An empty String
1045        // is encoded as its 8-byte length, so removing that suffix recreates
1046        // the exact pre-field GdnOp layout emitted by a v13 producer.
1047        assert_eq!(&bytes[bytes.len() - 8..], &[0; 8]);
1048        bytes.truncate(bytes.len() - 8);
1049        bytes[..4].copy_from_slice(&13u32.to_le_bytes());
1050
1051        match EngineSpec::from_bincode(&bytes) {
1052            Err(AicError::UnsupportedSchemaVersion {
1053                kind,
1054                got,
1055                expected,
1056            }) => {
1057                assert_eq!(kind, "EngineSpec");
1058                assert_eq!(got, 13);
1059                assert_eq!(expected, ENGINE_SPEC_SCHEMA_VERSION);
1060            }
1061            other => {
1062                panic!("expected UnsupportedSchemaVersion for a v13 GDN payload, got {other:?}")
1063            }
1064        }
1065    }
1066
1067    /// A correct version but an undecodable op payload is NOT a version skew: it
1068    /// must surface as an op-payload-stage `EngineSpec` error (op-layout drift
1069    /// within a version, or corruption), naming the stage and the version.
1070    #[test]
1071    fn from_bincode_matching_version_corrupt_ops_names_op_payload_stage() {
1072        let mut bytes = handshake_spec().to_bincode().expect("to_bincode");
1073        // Leave the version prefix intact; corrupt the trailing op payload.
1074        bytes.truncate(bytes.len() - 8);
1075
1076        match EngineSpec::from_bincode(&bytes) {
1077            Err(AicError::EngineSpec(msg)) => {
1078                assert!(
1079                    msg.contains("op payloads"),
1080                    "message should name the op-payload stage, got: {msg}"
1081                );
1082                assert!(
1083                    msg.contains(&ENGINE_SPEC_SCHEMA_VERSION.to_string()),
1084                    "message should cite the matching version, got: {msg}"
1085                );
1086            }
1087            other => panic!("expected EngineSpec op-payload error, got {other:?}"),
1088        }
1089    }
1090
1091    /// A well-formed wire buffer whose embedded `engine_json` is not valid JSON
1092    /// must fail at the engine-JSON stage (after the version gate and op decode
1093    /// both pass), naming that stage.
1094    #[test]
1095    fn from_bincode_invalid_engine_json_names_json_stage() {
1096        // Hand-build a wire buffer with the current version, empty op lists, and
1097        // a deliberately malformed `engine_json`.
1098        let wire = BincodeWire {
1099            schema_version: ENGINE_SPEC_SCHEMA_VERSION,
1100            engine_json: "this is not json".to_string(),
1101            context_ops: vec![],
1102            generation_ops: vec![],
1103        };
1104        let bytes = bincode::serialize(&wire).expect("serialize wire");
1105
1106        match EngineSpec::from_bincode(&bytes) {
1107            Err(AicError::EngineSpec(msg)) => {
1108                assert!(
1109                    msg.contains("engine JSON"),
1110                    "message should name the engine-JSON stage, got: {msg}"
1111                );
1112            }
1113            other => panic!("expected EngineSpec engine-JSON error, got {other:?}"),
1114        }
1115    }
1116}