atlas_core/config/
dispatch.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Top-level model-type dispatch for [`super::parse_config`]. Split out of
4//! `config.rs` for file-size budget — handles the JSON `model_type` field
5//! and routes to the appropriate parser sub-module.
6
7#![allow(unused_imports)]
8
9use anyhow::{Context, Result};
10
11use super::{
12    LayerType, ModelConfig, default_conv_kernel, default_partial_rotary, default_rms_eps,
13    default_rope_theta, finalize_config, parse_deepseek_v4, parse_gemma4_params, parse_glm5_next,
14    parse_kimi_k3, parse_laguna, parse_longcat_ngram, parse_minimax_m2, parse_mistral_params,
15    parse_quantization_config, parse_qwen4_exp, parse_step3p7, parse_vision_config,
16    sanitize_kimi_k3_eos, validate_config,
17};
18
19fn required_u64(raw: &serde_json::Value, key: &str, model_type: &str) -> Result<u64> {
20    let value = raw
21        .get(key)
22        .with_context(|| format!("{model_type} config missing required field `{key}`"))?;
23    value
24        .as_u64()
25        .with_context(|| format!("{model_type} config field `{key}` must be an unsigned integer"))
26}
27
28fn required_nonzero_usize(raw: &serde_json::Value, key: &str, model_type: &str) -> Result<usize> {
29    let value = required_u64(raw, key, model_type)? as usize;
30    if value == 0 {
31        anyhow::bail!("{model_type} config field `{key}` must be greater than zero");
32    }
33    Ok(value)
34}
35
36fn required_u32(raw: &serde_json::Value, key: &str, model_type: &str) -> Result<u32> {
37    let value = required_u64(raw, key, model_type)?;
38    u32::try_from(value)
39        .with_context(|| format!("{model_type} config field `{key}` does not fit in u32"))
40}
41
42/// Parse a checkpoint `config.json` into a [`ModelConfig`].
43///
44/// Thin wrapper: the per-family dispatch is unchanged in `parse_config_dispatch`, and the only
45/// added step is populating the complete stop-token set. That step is ADDITIVE — it never
46/// changes `eos_token_id`, so every existing model behaves exactly as before.
47pub fn parse_config(json: &str) -> Result<ModelConfig> {
48    let mut config = parse_config_dispatch(json)?;
49    populate_eos_token_ids(&mut config, json);
50    sanitize_kimi_k3_eos(&mut config);
51    Ok(config)
52}
53
54/// Collect every declared stop-token id, primary first.
55///
56/// HF allows `eos_token_id` to be a scalar OR an array, at the top level or inside
57/// `text_config`. Family parsers collapse the array to one id because `ModelConfig::eos_token_id`
58/// is a `u32` — this recovers the rest instead of losing them.
59///
60/// Ordering contract: `config.eos_token_id` is always element 0, whatever the family parser
61/// chose (laguna takes the first, step3p7 deliberately takes the LAST). The remaining declared
62/// ids follow in config order, de-duplicated. Nothing here overrides a parser's primary choice.
63#[cfg(test)]
64pub(super) fn populate_eos_token_ids_for_test(config: &mut ModelConfig, json: &str) {
65    populate_eos_token_ids(config, json);
66}
67
68fn populate_eos_token_ids(config: &mut ModelConfig, json: &str) {
69    let Ok(raw) = serde_json::from_str::<serde_json::Value>(json) else {
70        return;
71    };
72    let mut ids: Vec<u32> = vec![config.eos_token_id];
73    let mut push = |v: &serde_json::Value| match v {
74        serde_json::Value::Number(n) => {
75            if let Some(x) = n.as_u64() {
76                ids.push(x as u32);
77            }
78        }
79        serde_json::Value::Array(a) => {
80            for e in a {
81                if let Some(x) = e.as_u64() {
82                    ids.push(x as u32);
83                }
84            }
85        }
86        _ => {}
87    };
88    if let Some(v) = raw.get("eos_token_id") {
89        push(v);
90    }
91    if let Some(v) = raw.get("text_config").and_then(|t| t.get("eos_token_id")) {
92        push(v);
93    }
94    let mut seen = std::collections::BTreeSet::new();
95    ids.retain(|id| seen.insert(*id));
96    config.eos_token_ids = ids;
97}
98
99fn parse_config_dispatch(json: &str) -> Result<ModelConfig> {
100    // First, probe the top-level model_type.
101    let raw: serde_json::Value =
102        serde_json::from_str(json).context("Invalid JSON in config.json")?;
103
104    // A remote-code checkpoint may declare ONLY `architectures` + `auto_map`
105    // and no `model_type` at all — LongCat-Flash-Lite ships exactly that
106    // (`architectures: ["LongcatFlashNgramForCausalLM"]`). Without this
107    // fallback such a config silently falls through to the generic parse and
108    // loses its family, so map the known architecture names onto their
109    // model_type. Only consulted when `model_type` is absent/empty, so no
110    // existing checkpoint changes behaviour.
111    let top_model_type = raw
112        .get("model_type")
113        .and_then(serde_json::Value::as_str)
114        .filter(|s| !s.is_empty())
115        .or_else(|| {
116            raw.get("architectures")
117                .and_then(serde_json::Value::as_array)
118                .and_then(|a| a.first())
119                .and_then(serde_json::Value::as_str)
120                .and_then(|arch| match arch {
121                    "LongcatFlashNgramForCausalLM" => Some("longcat_flash_ngram"),
122                    "LongcatFlashForCausalLM" => Some("longcat_flash"),
123                    "Qwen4ExpForConditionalGeneration"
124                    | "Qwen3_8FlashNextForConditionalGeneration" => Some("qwen4_exp"),
125                    _ => None,
126                })
127        })
128        .unwrap_or("");
129
130    match top_model_type {
131        "qwen3_vl_moe" | "qwen3_5_moe" | "qwen3_5" => {
132            let text_config = raw
133                .get("text_config")
134                .context("qwen3_5_moe config missing text_config")?;
135            let mut config: ModelConfig = serde_json::from_value(text_config.clone())
136                .context("Failed to parse text_config")?;
137            // Override model_type to the top-level one (text_config has "*_text" suffix)
138            config.model_type = top_model_type.to_string();
139            // Weight prefix is auto-detected from store keys in main.rs after loading
140            // (different quantizers use different prefixes)
141            // eos_token_id from text_config
142            if config.eos_token_id == 0 {
143                config.eos_token_id = text_config
144                    .get("eos_token_id")
145                    .and_then(serde_json::Value::as_u64)
146                    .unwrap_or(0) as u32;
147            }
148            // Vocab size can also be at top level
149            if config.vocab_size == 0 {
150                config.vocab_size = raw
151                    .get("vocab_size")
152                    .and_then(serde_json::Value::as_u64)
153                    .unwrap_or(0) as usize;
154            }
155            // rope_theta and partial_rotary_factor from nested rope_parameters
156            if let Some(rope_params) = text_config.get("rope_parameters") {
157                if config.rope_theta == default_rope_theta()
158                    && let Some(theta) = rope_params
159                        .get("rope_theta")
160                        .and_then(serde_json::Value::as_f64)
161                {
162                    config.rope_theta = theta;
163                }
164                // FP8 checkpoints store partial_rotary_factor inside rope_parameters
165                if config.partial_rotary_factor == default_partial_rotary()
166                    && let Some(prf) = rope_params
167                        .get("partial_rotary_factor")
168                        .and_then(serde_json::Value::as_f64)
169                {
170                    config.partial_rotary_factor = prf;
171                }
172            }
173            // Qwen3.5 MoE unconditionally normalizes top-K expert weights
174            // (hardcoded in HF's Qwen3_5MoeTopKRouter, no config toggle).
175            config.norm_topk_prob = true;
176            // Architecture flags
177            config.nested_config = true;
178            config.attn_gated = top_model_type != "qwen3_vl_moe";
179            // Parse vision_config for VL models. Qwen3.6 also ships a ViT
180            // tower (detected via the mrope_interleaved flag set below,
181            // but we don't have that until after this block, so also
182            // trigger when the raw config has a `vision_config` key).
183            if top_model_type == "qwen3_vl_moe" || raw.get("vision_config").is_some() {
184                config.vision = parse_vision_config(&raw);
185            }
186            // MRoPE detection: Qwen3.6 MoE sets mrope_interleaved + mrope_section
187            // inside text_config.rope_parameters. When present on a MoE
188            // variant, rewrite model_type to "qwen3_6_moe" so kernel-target
189            // resolution picks the right directory (Qwen3.5-MoE and
190            // Qwen3.6-MoE share hidden_size=2048 and would otherwise collide).
191            // The backing weight loader stays in the qwen3_5 family — MoE
192            // architecture is identical except for MRoPE layout and the
193            // full-attention layer gate.
194            //
195            // Kbenkhaled's Qwen3.5-27B-NVFP4 is dense (top_model_type="qwen3_5",
196            // no experts) but also enables MRoPE. For dense, do NOT rewrite:
197            // the Qwen35 MoE weight loader would fail looking for mlp.gate.
198            // The qwen3.5-27b kernel target handles MRoPE at runtime via the
199            // mrope_interleaved / mrope_section flags.
200            if let Some(rope_params) = text_config.get("rope_parameters") {
201                if let Some(ms) = rope_params.get("mrope_section").and_then(|v| v.as_array())
202                    && ms.len() == 3
203                {
204                    config.mrope_section = [
205                        ms[0].as_u64().unwrap_or(0) as usize,
206                        ms[1].as_u64().unwrap_or(0) as usize,
207                        ms[2].as_u64().unwrap_or(0) as usize,
208                    ];
209                }
210                config.mrope_interleaved = rope_params
211                    .get("mrope_interleaved")
212                    .and_then(|v| v.as_bool())
213                    .unwrap_or(false);
214                let is_moe = top_model_type == "qwen3_5_moe" || top_model_type == "qwen3_vl_moe";
215                if is_moe
216                    && config.mrope_interleaved
217                    && config.mrope_section.iter().sum::<usize>() > 0
218                {
219                    config.model_type = "qwen3_6_moe".to_string();
220                }
221            }
222            // Holo-3.1 (Hcompany) is a fine-tune of Qwen3.6-35B-A3B and shares
223            // its ENTIRE config — same vision tower, same image_token_id
224            // (248056), same MRoPE layout. The one structural difference is
225            // that Hcompany strips the MTP head from its releases
226            // (text_config has no mtp_num_hidden_layers), while every
227            // official Qwen3.6-35B checkpoint ships mtp_num_hidden_layers=1.
228            // Gate on that: without it the flagship Qwen/Qwen3.6-35B-A3B-FP8
229            // was misdetected as holo3_1_moe and failed kernel-target
230            // resolution (targets declare qwen3_6_moe).
231            if top_model_type == "qwen3_5_moe"
232                && config.vision.is_some()
233                && config.mtp_num_hidden_layers == 0
234                && raw
235                    .get("image_token_id")
236                    .and_then(serde_json::Value::as_u64)
237                    == Some(248_056)
238            {
239                config.model_type = "holo3_1_moe".to_string();
240            }
241            finalize_config(&mut config, &raw)?;
242            Ok(config)
243        }
244        "nemotron_h" | "nemotron_h_puzzle" => {
245            // Puzzle: num_hidden_layers is JSON null and the hybrid schedule lives
246            // in layers_block_type / block_configs (per-block MoE channel pruning).
247            // Rewrite the JSON so serde can deserialize, then map to Atlas fields.
248            let mut raw_mut = raw.clone();
249            if top_model_type == "nemotron_h_puzzle" {
250                apply_nemotron_puzzle_json(&mut raw_mut)?;
251            }
252            let mut config: ModelConfig = serde_json::from_value(raw_mut.clone())
253                .context("Failed to parse nemotron_h config.json")?;
254            // Map Nemotron-H field names → Atlas canonical names
255            if config.num_experts == 0 && config.n_routed_experts > 0 {
256                config.num_experts = config.n_routed_experts;
257            }
258            if config.rms_norm_eps == default_rms_eps() && config.norm_eps > 0.0 {
259                config.rms_norm_eps = config.norm_eps;
260            }
261            if config.linear_conv_kernel_dim == default_conv_kernel() && config.conv_kernel > 0 {
262                config.linear_conv_kernel_dim = config.conv_kernel;
263            }
264            if config.shared_expert_intermediate_size == 0
265                && config.moe_shared_expert_intermediate_size > 0
266            {
267                config.shared_expert_intermediate_size = config.moe_shared_expert_intermediate_size;
268            }
269            // Architecture flags
270            config.attn_gated = false;
271            config.weight_prefix = "backbone".to_string();
272            // Parse hybrid_override_pattern → layer_types (Nano / Super)
273            if !config.hybrid_override_pattern.is_empty() && config.layer_types.is_empty() {
274                config.layer_types = config
275                    .hybrid_override_pattern
276                    .chars()
277                    .map(|c| match c {
278                        'M' => LayerType::LinearAttention,
279                        'E' => LayerType::Moe,
280                        '*' => LayerType::FullAttention,
281                        other => panic!("Unknown hybrid_override_pattern char: '{other}'"),
282                    })
283                    .collect();
284            }
285            // Puzzle: layers_block_type + block_configs → layer_types + per-layer MoE dims
286            if top_model_type == "nemotron_h_puzzle" {
287                apply_nemotron_puzzle_config(&mut config, &raw_mut)?;
288            }
289            finalize_config(&mut config, &raw_mut)?;
290            Ok(config)
291        }
292        "gemma4" => parse_gemma4_params(&raw),
293        "laguna" => parse_laguna(&raw),
294        "longcat_flash_ngram" | "longcat_flash" => parse_longcat_ngram(&raw),
295        // Nested text_config like qwen3_5_moe, but hyper-connections, the QSA
296        // indexer and PLE n-gram injection put it outside that arm.
297        //
298        // TWO NAMES, ONE ARCHITECTURE. Qwen3.8-Flash-Next shipped under
299        // `qwen3_8_flash_next` and was later renamed `qwen4_exp`; quantizers
300        // pinned to different transformers revisions emit different names
301        // (RadixArk -> qwen4_exp, Inferact -> qwen3_8_flash_next). Their
302        // `text_config`s are otherwise IDENTICAL field-for-field, so the
303        // alias is the whole difference at the config layer.
304        "qwen4_exp" | "qwen3_8_flash_next" => parse_qwen4_exp(&raw),
305        "m2m_100" | "nllb" => {
306            let mut config = ModelConfig::qwen3_next_80b_nvfp4();
307            config.model_type = "m2m_100".to_string();
308            config.hidden_size = required_nonzero_usize(&raw, "d_model", top_model_type)?;
309            config.num_hidden_layers =
310                required_nonzero_usize(&raw, "decoder_layers", top_model_type)?;
311            config.intermediate_size =
312                required_nonzero_usize(&raw, "decoder_ffn_dim", top_model_type)?;
313            config.vocab_size = required_nonzero_usize(&raw, "vocab_size", top_model_type)?;
314            config.num_attention_heads =
315                required_nonzero_usize(&raw, "decoder_attention_heads", top_model_type)?;
316            config.num_key_value_heads = config.num_attention_heads;
317            if !config
318                .hidden_size
319                .is_multiple_of(config.num_attention_heads)
320            {
321                anyhow::bail!(
322                    "{} config has d_model ({}) not divisible by decoder_attention_heads ({})",
323                    top_model_type,
324                    config.hidden_size,
325                    config.num_attention_heads,
326                );
327            }
328            config.head_dim = config.hidden_size / config.num_attention_heads;
329            config.max_position_embeddings =
330                required_nonzero_usize(&raw, "max_position_embeddings", top_model_type)?;
331            config.bos_token_id = required_u32(&raw, "bos_token_id", top_model_type)?;
332            config.eos_token_id = required_u32(&raw, "eos_token_id", top_model_type)?;
333            config.tie_word_embeddings = true;
334            config.attn_gated = false;
335            config.weight_prefix = "model.decoder".to_string();
336            config.num_experts = 0;
337            config.num_experts_per_tok = 1;
338            config.moe_intermediate_size = 0;
339            config.shared_expert_intermediate_size = 0;
340            config.layer_types.clear();
341            config.full_attention_interval = 1;
342            config.linear_num_key_heads = 0;
343            config.linear_key_head_dim = 0;
344            config.linear_num_value_heads = 0;
345            config.linear_value_head_dim = 0;
346            config.mtp_num_hidden_layers = 0;
347            config.vision = None;
348            config.quantization_config = parse_quantization_config(&raw);
349            validate_config(&config)?;
350            Ok(config)
351        }
352        "minimax_m2" => parse_minimax_m2(&raw),
353        "step3p7" => parse_step3p7(&raw),
354        "deepseek_v4" => parse_deepseek_v4(json),
355        // GLM-5.3-Flash. Nested text_config + NoPE MLA (qk_rope_head_dim == 0):
356        // must NOT fall through to the flat branch, which would leave
357        // layer_types empty and the KDA geometry unset.
358        "glm5_next" | "glm5_next_text" => parse_glm5_next(json),
359        // Kimi K3. Nested `text_config` like GLM-5.3; inner `model_type` is
360        // `kimi_linear`. Canonicalise to `kimi_k3` — do NOT alias onto
361        // deepseek_v3 / glm5_next. Twin checkpoints may ship flat `kimi_linear`.
362        "kimi_k3" | "kimi_linear" => parse_kimi_k3(json),
363        _ => {
364            // Flat config (qwen3_next, etc.)
365            let mut config: ModelConfig =
366                serde_json::from_str(json).context("Failed to parse config.json")?;
367            config.attn_gated = true;
368            finalize_config(&mut config, &raw)?;
369            Ok(config)
370        }
371    }
372}
373
374/// Rewrite Puzzle HF JSON so serde can load it as `ModelConfig`.
375///
376/// - `num_hidden_layers` is JSON null → derive from `layers_block_type` length
377/// - scalar `moe_intermediate_size` / `num_experts_per_tok` may be absent → fill
378///   with max-over-blocks so uniform Super-style code paths still have defaults
379fn apply_nemotron_puzzle_json(raw: &mut serde_json::Value) -> Result<()> {
380    let obj = raw
381        .as_object_mut()
382        .context("nemotron_h_puzzle config.json is not an object")?;
383    let n_layers = obj
384        .get("layers_block_type")
385        .and_then(|v| v.as_array())
386        .map(|a| a.len())
387        .or_else(|| {
388            obj.get("block_configs")
389                .and_then(|v| v.as_array())
390                .map(|a| a.len())
391        })
392        .context("nemotron_h_puzzle missing layers_block_type / block_configs")?;
393    if obj
394        .get("num_hidden_layers")
395        .map(|v| v.is_null() || v.as_u64() == Some(0))
396        .unwrap_or(true)
397    {
398        obj.insert("num_hidden_layers".into(), serde_json::json!(n_layers));
399    }
400    // Collect max MoE dims from block_configs for scalar fallbacks
401    let mut max_inter = 0usize;
402    let mut max_topk = 0usize;
403    if let Some(blocks) = obj.get("block_configs").and_then(|v| v.as_array()) {
404        for b in blocks {
405            if let Some(mi) = b.get("moe_intermediate_size").and_then(|v| v.as_u64()) {
406                max_inter = max_inter.max(mi as usize);
407            }
408            if let Some(tk) = b.get("num_experts_per_tok").and_then(|v| v.as_u64()) {
409                max_topk = max_topk.max(tk as usize);
410            }
411        }
412    }
413    if obj
414        .get("moe_intermediate_size")
415        .and_then(|v| v.as_u64())
416        .unwrap_or(0)
417        == 0
418        && max_inter > 0
419    {
420        obj.insert("moe_intermediate_size".into(), serde_json::json!(max_inter));
421    }
422    if obj
423        .get("num_experts_per_tok")
424        .and_then(|v| v.as_u64())
425        .unwrap_or(0)
426        == 0
427        && max_topk > 0
428    {
429        obj.insert("num_experts_per_tok".into(), serde_json::json!(max_topk));
430    }
431    Ok(())
432}
433
434/// Map Puzzle `layers_block_type` / `block_configs` onto Atlas layer schedule.
435fn apply_nemotron_puzzle_config(config: &mut ModelConfig, raw: &serde_json::Value) -> Result<()> {
436    let block_types = raw
437        .get("layers_block_type")
438        .and_then(|v| v.as_array())
439        .context("nemotron_h_puzzle missing layers_block_type")?;
440    config.layer_types = block_types
441        .iter()
442        .map(|v| {
443            let s = v.as_str().unwrap_or("");
444            Ok(match s {
445                "mamba" => LayerType::LinearAttention,
446                "moe" => LayerType::Moe,
447                "attention" => LayerType::FullAttention,
448                other => anyhow::bail!("unknown layers_block_type entry: '{other}'"),
449            })
450        })
451        .collect::<Result<Vec<_>>>()?;
452    if config.num_hidden_layers == 0 {
453        config.num_hidden_layers = config.layer_types.len();
454    }
455    // Per-layer MoE schedule from block_configs
456    let n = config.num_hidden_layers;
457    let mut inters = vec![0usize; n];
458    let mut topks = vec![0usize; n];
459    if let Some(blocks) = raw.get("block_configs").and_then(|v| v.as_array()) {
460        for (i, b) in blocks.iter().enumerate().take(n) {
461            if b.get("block_type").and_then(|v| v.as_str()) != Some("moe") {
462                continue;
463            }
464            inters[i] = b
465                .get("moe_intermediate_size")
466                .and_then(|v| v.as_u64())
467                .unwrap_or(0) as usize;
468            topks[i] = b
469                .get("num_experts_per_tok")
470                .and_then(|v| v.as_u64())
471                .unwrap_or(0) as usize;
472        }
473    }
474    config.moe_intermediate_sizes = inters;
475    config.num_experts_per_toks = topks;
476    // Keep scalar fields as max for buffer defaults / logging
477    if let Some(m) = config
478        .moe_intermediate_sizes
479        .iter()
480        .copied()
481        .filter(|&s| s > 0)
482        .max()
483    {
484        config.moe_intermediate_size = m;
485    }
486    if let Some(m) = config
487        .num_experts_per_toks
488        .iter()
489        .copied()
490        .filter(|&k| k > 0)
491        .max()
492    {
493        config.num_experts_per_tok = m;
494    }
495    Ok(())
496}