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