spark_model/
factory.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Model factory: builds the right model from config + weights.
4//!
5//! Weight loader selection is registry-driven — add new models by implementing
6//! [`ModelWeightLoader`] and registering in [`loader_for_config`]. No other
7//! code changes needed.
8
9use anyhow::{Result, bail};
10use avarok_core::config::ModelConfig;
11use spark_runtime::weights::WeightStore;
12
13use crate::mistral_loader::MistralWeightLoader;
14use crate::weight_loader::LongcatWeightLoader;
15use crate::weight_loader::Qwen4ExpWeightLoader;
16use crate::weight_loader::{
17    DeepSeekV4WeightLoader, DflashConfig, Gemma4WeightLoader, Glm5NextWeightLoader,
18    KimiK3WeightLoader, LagunaWeightLoader, MinimaxM2WeightLoader, ModelWeightLoader,
19    NemotronHWeightLoader, NllbWeightLoader, Qwen3VLWeightLoader, Qwen3WeightLoader,
20    Qwen35DenseWeightLoader, Qwen35WeightLoader, Step3p7WeightLoader,
21};
22
23/// DFlash speculative-decoding build arguments. `None` for non-DFlash runs;
24/// `Some(...)` carries the drafter's separate [`WeightStore`], parsed
25/// `config.json`, and CLI overrides for γ and the sliding-window size.
26///
27/// Construction order: the caller (`spark-server::main`) loads the drafter
28/// checkpoint into a fresh [`WeightStore`] via the same `WeightStore::load`
29/// path used for the target, then parses `config.json` via
30/// [`crate::weight_loader::dflash_loader::parse_dflash_config`]. Both inputs
31/// flow through to [`build_model`] which validates dimensions against the
32/// target before constructing [`crate::layers::BlockDiffusionDraftHead`].
33pub struct DflashBuildArgs<'a> {
34    pub drafter_store: &'a WeightStore,
35    pub drafter_config: DflashConfig,
36    pub gamma: Option<usize>,
37    pub window_size: Option<usize>,
38}
39
40/// LoRA adapter build arguments (`--lora-adapter NAME=PATH`). `None` for
41/// base-only runs; `Some(...)` carries the adapter's separate on-device
42/// [`WeightStore`] (loaded via
43/// `spark_runtime::weights::adapter::load_adapter_safetensors`), the parsed
44/// `adapter_config.json`, and the pool-shape CLI knobs.
45///
46/// Unlike DFlash (loaded post-construction), the LoRA pool is allocated at
47/// the TOP of `build_model` — before the buffer arena and the free-memory
48/// snapshot — so its bytes are automatically debited from the KV budget.
49pub struct LoraBuildArgs<'a> {
50    /// One or more adapters to pack (repeated `--lora-adapter NAME=PATH`),
51    /// each carrying its NAME, its on-device `WeightStore`, and its parsed
52    /// `adapter_config.json`. Slot k = `adapters[k]`. A single element is
53    /// byte-identical to the pre-multi-adapter path.
54    pub adapters: Vec<crate::lora::LoraAdapterInput<'a>>,
55    pub max_lora_rank: usize,
56    pub max_loras: usize,
57}
58
59// ── Loader registry ─────────────────────────────────────────────────────────
60// Adding a new model: implement ModelWeightLoader and add a match arm below.
61// Everything else (KV cache, buffers, TransformerModel) is model-agnostic.
62
63/// Select the weight loader for a given model config.
64///
65/// This is the ONLY place model_type strings are matched. All downstream
66/// code is model-agnostic.
67pub fn loader_for_config(config: &ModelConfig) -> Result<Box<dyn ModelWeightLoader>> {
68    let normalized = config.model_type.to_lowercase().replace(['-', '.'], "_");
69    match normalized.as_str() {
70        // Qwen3 family: sub-dispatch by config predicates
71        "kimi_k3" | "kimi_linear" => Ok(Box::new(KimiK3WeightLoader)),
72        "qwen3_next" => Ok(Box::new(Qwen3WeightLoader)),
73        "qwen3_vl_moe" => Ok(Box::new(Qwen3VLWeightLoader)),
74        "qwen3_5_moe" | "qwen3_5" | "qwen35_moe" | "qwen35" => {
75            // Dense check has to come first. Qwen3.6-27B-FP8 is the dense text
76            // sibling of the Qwen3.6 VL family — its config declares the same
77            // `vision_config` block as the MoE-VL siblings (so `is_qwen3_vl()`
78            // returns true), but the checkpoint ships no vision tower and no
79            // MoE router, so the VL loader panics on a missing `mlp.gate`.
80            // `is_qwen35_dense()` requires `num_experts == 0`, which only the
81            // dense text models satisfy — VL-MoE always has experts.
82            if config.is_qwen35_dense() {
83                Ok(Box::new(Qwen35DenseWeightLoader))
84            } else if config.is_qwen3_vl() {
85                Ok(Box::new(Qwen3VLWeightLoader))
86            } else {
87                Ok(Box::new(Qwen35WeightLoader))
88            }
89        }
90        // Qwen3.6: identical architecture to Qwen3.5 MoE at the weight level
91        // (GDN + full-attention + MoE hybrid, same expert layout, same MTP).
92        // Only difference is MRoPE-interleaved layout + attn_output_gate on
93        // full-attention layers — both handled at forward-pass layer time,
94        // not during weight loading.
95        "qwen3_6_moe" | "holo3_1_moe" => Ok(Box::new(Qwen35WeightLoader)),
96        // Nemotron-H family (Mamba-2 + MoE + Attention), including Puzzle
97        // (heterogeneous per-block MoE intermediate / top-k).
98        "nemotron_h" | "nemotron_h_puzzle" => Ok(Box::new(NemotronHWeightLoader)),
99        // NLLB / M2M-100 encoder-decoder translation family.
100        "m2m_100" | "nllb" => Ok(Box::new(NllbWeightLoader)),
101        // Gemma-4 family (pure attention, GeGLU, sliding + full attention)
102        "gemma4" | "gemma_4" => Ok(Box::new(Gemma4WeightLoader)),
103        // Mistral family (MLA + MoE, GQA fallback for initial bring-up)
104        "mistral" => Ok(Box::new(MistralWeightLoader)),
105        // LongCat-Flash(-Lite): MLA dual-sublayer blocks + shortcut MoE with
106        // zero-computation experts (+ n-gram input embeddings).
107        "longcat_flash_ngram" | "longcat_flash" => Ok(Box::new(LongcatWeightLoader)),
108        // Qwen3.8-Flash-Next. `dispatch.rs` normalizes the older
109        // `qwen3_8_flash_next` naming onto `qwen4_exp`, so one arm covers both
110        // published quantizations.
111        "qwen4_exp" => Ok(Box::new(Qwen4ExpWeightLoader)),
112        // MiniMax M2 family (M2.1 / M2.7) — full attention + 256-expert
113        // sigmoid-routed MoE + 3-module MTP.
114        "minimax_m2" => Ok(Box::new(MinimaxM2WeightLoader)),
115        // Step 3.7 Flash — 288-expert sigmoid-routed MoE + shared expert +
116        // mixed full/sliding attention + attention gate + 3 MTP modules.
117        "step3p7" => Ok(Box::new(Step3p7WeightLoader)),
118        "laguna" => Ok(Box::new(LagunaWeightLoader)),
119        // DeepSeek-V4 family (Flash) — MLA + MoE + CSA/HCA hybrid attention + mHC.
120        "deepseek_v4" => Ok(Box::new(DeepSeekV4WeightLoader)),
121        // GLM-5.3-Flash — NoPE MLA behind a DSA kpool indexer + KDA linear attention +
122        // 288-expert sigmoid-routed MoE + mHC. `glm5_next_text` is the inner `model_type`;
123        // the parser canonicalises both onto `glm5_next`.
124        "glm5_next" | "glm5_next_text" => Ok(Box::new(Glm5NextWeightLoader)),
125        // DeepSeek-V4.1 Flash. Ingestion (S1) is complete: the GGUF arch maps,
126        // all seven shards resolve, a full ModelConfig builds from the file's
127        // own metadata, and all 1,046 tensor names translate. The GRAPH is not
128        // built yet — V4.1 adds engram (two ~30 GiB hash tables), shared
129        // compressed attention (`SharedAttentionRuntime`, KV produced by four
130        // layers for forty) and a reworked indexer, none of which V4 has.
131        //
132        // This arm exists so the failure says which of those two things is
133        // missing. Falling through to the catch-all below would report
134        // "Unsupported model type", which is false and sends the reader looking
135        // for a config problem.
136        // DeepSeek-V4.1 Flash: the seven-shard Q2_K GGUF, routed experts and the
137        // engram tables streamed from disk (see weight_loader/deepseek_v41.rs).
138        "deepseek_v41" => Ok(Box::new(
139            crate::weight_loader::deepseek_v41::DeepSeekV41WeightLoader,
140        )),
141        _ => bail!(
142            "Unsupported model type: '{}' (normalized: '{}'). \
143             Supported: qwen3_next, glm5_next, qwen3_5_moe, qwen3_5, qwen3_6_moe, holo3_1_moe, qwen3_vl_moe, nemotron_h, nemotron_h_puzzle, gemma4, mistral, minimax_m2, step3p7, laguna, deepseek_v4, qwen4_exp, m2m_100, deepseek_v41",
144            config.model_type,
145            normalized,
146        ),
147    }
148}
149
150mod build;
151mod lm_head_setup;
152mod m2_setup;
153
154pub use build::build_model;
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::layers::mtp_head::MtpQuantization;
160    use spark_runtime::kv_cache::KvCacheDtype;
161    use spark_runtime::prefix_cache::PrefixCache;
162
163    #[test]
164    fn test_unsupported_model_type() {
165        let mut config = ModelConfig::qwen3_next_80b_nvfp4();
166        config.model_type = "llama".to_string();
167
168        let gpu = spark_runtime::gpu::mock::MockGpuBackend::new();
169        let store = WeightStore::empty();
170
171        let prefix_cache: Box<dyn PrefixCache> =
172            Box::new(spark_runtime::prefix_cache::NoPrefixCaching);
173        let result = build_model(
174            config,
175            store,
176            Box::new(gpu),
177            1,
178            16,
179            4096,
180            8,
181            MtpQuantization::Nvfp4,
182            false,
183            prefix_cache,
184            0,
185            None,
186            false,
187            1,
188            KvCacheDtype::Fp8,
189            1024 * 1024 * 1024,
190            0.90,
191            0,
192            vec![],
193            0,
194            None,
195            None, // dflash_args
196            None, // lora_args
197            None, // nllb_lang
198            None, // nllb_lora_dir
199        );
200        match result {
201            Err(e) => assert!(e.to_string().contains("Unsupported model type: 'llama'")),
202            Ok(_) => panic!("Expected error for unsupported model type"),
203        }
204    }
205
206    #[test]
207    fn all_declared_model_type_spellings_are_accepted() {
208        let mut config = ModelConfig::qwen3_next_80b_nvfp4();
209        for model_type in [
210            "qwen3_next",
211            "qwen3_vl_moe",
212            "qwen3_5_moe",
213            "qwen3_5",
214            "qwen35_moe",
215            "qwen35",
216            "qwen3_6_moe",
217            "holo3_1_moe",
218            "nemotron_h",
219            "nemotron_h_puzzle",
220            "m2m_100",
221            "nllb",
222            "gemma4",
223            "gemma_4",
224            "mistral",
225            "minimax_m2",
226            "step3p7",
227            "laguna",
228            "deepseek_v4",
229            // Normalization accepts case, hyphens, and dots before dispatch.
230            "QWEN3-NEXT",
231            "nemotron.h.puzzle",
232            "M2M-100",
233        ] {
234            config.model_type = model_type.to_string();
235            assert!(
236                loader_for_config(&config).is_ok(),
237                "declared model type {model_type:?} was rejected"
238            );
239        }
240
241        config.model_type = "unsupported_model".to_string();
242        assert!(loader_for_config(&config).is_err());
243    }
244
245    // The generic `ModelWeightLoader` for NLLB must fail fast: real NLLB serving
246    // goes through the dedicated `NllbGpuModel` encoder-decoder runtime, which
247    // `build_model` selects before this loader is ever consulted. Reaching this
248    // loader is a routing bug, so every mandatory generic tensor-loading
249    // entry point bails. Optional hooks still report their normal absence.
250    #[test]
251    fn nllb_mandatory_generic_loads_fail_fast() {
252        let mut config = ModelConfig::qwen3_next_80b_nvfp4();
253        config.model_type = "nllb".to_string();
254        let loader = loader_for_config(&config).unwrap();
255        let store = WeightStore::empty();
256        let gpu = spark_runtime::gpu::mock::MockGpuBackend::new();
257
258        let errors = [
259            loader
260                .load_layers(&store, &config, &gpu, &[])
261                .err()
262                .expect("generic layer load unexpectedly succeeded"),
263            loader
264                .load_embedding(&store, &config, &gpu)
265                .expect_err("generic embedding load unexpectedly succeeded"),
266            loader
267                .load_final_norm(&store, &config, &gpu)
268                .expect_err("generic final-norm load unexpectedly succeeded"),
269            loader
270                .load_lm_head(&store, &config, &gpu)
271                .expect_err("generic LM-head load unexpectedly succeeded"),
272        ];
273        for err in errors {
274            assert!(
275                err.to_string()
276                    .contains("dedicated GPU encoder-decoder runtime"),
277                "unexpected fail-fast diagnostic: {err}"
278            );
279        }
280    }
281}