avarok_core/config/parsers/
glm5_next.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! GLM-5.3-Flash (`glm5_next`) config parser.
4//!
5//! Reference checkpoint: `LibertAIDAI/GLM-5.3-Flash-NVFP4` snapshot
6//! `9e0d74e3cef17f634e84fb8e2223707e02616290`. All constants asserted in the
7//! tests below were read from that checkpoint's `config.json` and from a scan
8//! of all 120 shard headers. Claims are scoped to that checkpoint.
9//!
10//! Two things make this model unlike anything already parsed here:
11//!
12//! 1. **NoPE MLA.** `qk_rope_head_dim` is `0`. The DeepSeek-V4 parser derives
13//!    `qk_nope_head_dim = head_dim - qk_rope_head_dim` and only does so when
14//!    `qk_rope_head_dim > 0`, i.e. it encodes "there is a rope section" as an
15//!    invariant. We must NOT reuse it: a zero here is a fact to preserve, not a
16//!    missing value to repair. Upstream vLLM's equivalent assumption is exactly
17//!    what made its SM120 sparse-MLA backend structurally unusable for this
18//!    model (`pe_dim must be 64 for fp8_ds_mla`).
19//!
20//! 2. **Hybrid layer stack.** 45 text layers alternate KDA linear attention
21//!    with DeepSeek-style sparse attention, and the checkpoint states the split
22//!    explicitly in `linear_attn_config.{kda_layers,full_attn_layers}`. We trust
23//!    that list over any modular arithmetic, and cross-check it against the
24//!    `layer_types` array when present.
25
26use anyhow::{Context, Result, bail};
27
28use super::super::{Glm5NextRouterMode, LayerType, ModelConfig, finalize_config};
29
30/// `num_hidden_layers` counts text layers only; the MTP layer sits at index
31/// `num_hidden_layers` (45) and is NOT included in that count.
32///
33/// 🪤 GLM-5.3 does **not** use DeepSeek's `mtp.0.*` naming — the MTP weights
34/// live under `model.language_model.layers.45.*`. A `grep mtp` over this
35/// checkpoint returns zero hits. Verified over all 120 shard headers.
36pub fn glm5_next_mtp_layer_index(config: &ModelConfig) -> usize {
37    config.num_hidden_layers
38}
39
40fn text_config(raw: &serde_json::Value) -> &serde_json::Value {
41    raw.get("text_config").unwrap_or(raw)
42}
43
44/// GLM's own name for its sparse-MLA mixer.
45///
46/// Since Slice 8 this maps to [`LayerType::SparseAttention`], a real variant, and the
47/// array **round-trips**: `layer_types[i].hf_name()` reproduces the checkpoint string.
48/// It used to be flattened onto `FullAttention` "for scheduling purposes" — which was
49/// only true while nothing scheduled on it. A sparse layer needs indexer state, an
50/// indexer weight family and a per-query top-k step, so cache sizing and weight binding
51/// have to be able to tell the two apart.
52pub const GLM5NEXT_SPARSE_ATTN: &str = "deepseek_sparse_attention";
53
54pub fn parse_glm5_next(json: &str) -> Result<ModelConfig> {
55    let raw: serde_json::Value =
56        serde_json::from_str(json).context("Invalid JSON in GLM-5.3 (glm5_next) config.json")?;
57
58    // GLM-5.3 nests everything under `text_config` (the top level carries the
59    // multimodal wrapper + `quantization_config`). Parse the inner object, but
60    // keep the outer one for quantization and for the architecture string.
61    let text = text_config(&raw).clone();
62
63    // `deepseek_sparse_attention` now deserializes onto `LayerType::SparseAttention` via a
64    // serde alias, so the strip below is no longer required for parsing to succeed. It stays
65    // because `build_layer_types` derives the array from `linear_attn_config`'s index lists
66    // (the authoritative source) and then CROSS-CHECKS it against the textual array; letting
67    // serde populate the field first would make that cross-check compare a value against
68    // itself.
69    let mut text_for_struct = text.clone();
70    if let Some(obj) = text_for_struct.as_object_mut() {
71        obj.remove("layer_types");
72        // 🔴 GLM-5.3-Flash declares THREE stop tokens — `eos_token_id` is an ARRAY:
73        // 154820 `<|endoftext|>`, 154827 `<|user|>`, 154829 `<|observation|>`. Before Slice 9
74        // that made this checkpoint's config.json fail to parse outright ("invalid type:
75        // sequence, expected u32"); the parser had only ever seen a hand-written fixture with
76        // no `eos_token_id` at all. Handled centrally now — `eos_token_id_field` takes element
77        // 0 as the primary (`tokenizer_config.json` names `<|endoftext|>` as THE `eos_token`)
78        // and `parse_config` fills `eos_token_ids` with the complete set.
79        // Read stop tokens through `ModelConfig::eos_ids()` / `is_eos()`, never off the scalar.
80    }
81    let text_json =
82        serde_json::to_string(&text_for_struct).context("re-serialize glm5_next text_config")?;
83    let mut config: ModelConfig =
84        serde_json::from_str(&text_json).context("Failed to parse glm5_next text_config")?;
85    let text = &text;
86
87    // Canonical model_type. The inner object says `glm5_next_text`; Atlas keys
88    // dispatch off the outer family name.
89    config.model_type = "glm5_next".to_string();
90
91    // ---- MLA geometry -----------------------------------------------------
92    // NoPE: qk_rope_head_dim is legitimately 0 and must survive untouched.
93    // Do not "repair" it, and do not derive qk_nope from it.
94    let qk_rope = text
95        .get("qk_rope_head_dim")
96        .and_then(|v| v.as_u64())
97        .map(|v| v as usize);
98    match qk_rope {
99        Some(v) => config.qk_rope_head_dim = v,
100        // Absent (not zero) would mean an unfamiliar variant: refuse rather
101        // than silently assume NoPE.
102        None => bail!(
103            "glm5_next config.json has no qk_rope_head_dim; refusing to guess \
104             whether this checkpoint is NoPE"
105        ),
106    }
107    if let Some(v) = text.get("qk_nope_head_dim").and_then(|v| v.as_u64()) {
108        config.qk_nope_head_dim = v as usize;
109    }
110    if let Some(v) = text.get("v_head_dim").and_then(|v| v.as_u64()) {
111        config.v_head_dim = v as usize;
112    }
113    // `head_dim` is 0 in the checkpoint. For MLA the meaningful per-head width
114    // is qk_head_dim (256), NOT hidden_size / num_attention_heads (which would
115    // give 64 and silently corrupt every attention shape) — the same trap the
116    // DeepSeek-V4 parser documents for its own head_dim.
117    if config.head_dim == 0 {
118        config.head_dim = text
119            .get("qk_head_dim")
120            .and_then(|v| v.as_u64())
121            .map(|v| v as usize)
122            .unwrap_or(config.qk_nope_head_dim + config.qk_rope_head_dim);
123    }
124    // partial_rotary_factor is rope_dim / head_dim; NoPE makes it exactly 0.
125    config.partial_rotary_factor = if config.head_dim > 0 {
126        config.qk_rope_head_dim as f64 / config.head_dim as f64
127    } else {
128        0.0
129    };
130
131    // ---- MoE --------------------------------------------------------------
132    if config.num_experts == 0 && config.n_routed_experts > 0 {
133        config.num_experts = config.n_routed_experts;
134    }
135    let n_shared = text
136        .get("n_shared_experts")
137        .and_then(|v| v.as_u64())
138        .unwrap_or(0) as usize;
139    if config.shared_expert_intermediate_size == 0 && n_shared > 0 {
140        config.shared_expert_intermediate_size = n_shared * config.moe_intermediate_size;
141    }
142
143    // ---- KDA linear attention --------------------------------------------
144    // The checkpoint carries these under `linear_attn_config`, not as the
145    // flat `linear_*` keys Atlas uses for Qwen GDN.
146    let lac = text.get("linear_attn_config");
147    if let Some(lac) = lac {
148        let g = |k: &str| lac.get(k).and_then(|v| v.as_u64()).map(|v| v as usize);
149        if let Some(v) = g("num_heads") {
150            config.linear_num_key_heads = v;
151            config.linear_num_value_heads = v;
152        }
153        if let Some(v) = g("head_dim") {
154            config.linear_key_head_dim = v;
155            config.linear_value_head_dim = v;
156        }
157        if let Some(v) = g("short_conv_kernel_size") {
158            config.linear_conv_kernel_dim = v;
159        }
160        // Bounds the log-decay `kda_gate` emits. Absent means an unfamiliar KDA variant, not
161        // "no bound" — 0.0 would clamp every decay to <= 0 and is a different model.
162        match lac.get("gate_lower_bound").and_then(|v| v.as_f64()) {
163            Some(v) => config.linear_gate_lower_bound = v as f32,
164            None => bail!(
165                "glm5_next: linear_attn_config has no gate_lower_bound; refusing to guess the \
166                 KDA decay bound (GLM-5.3-Flash declares -5.0)"
167            ),
168        }
169    }
170
171    // ---- DSA indexer ------------------------------------------------------
172    // Default only if truly absent. GLM's value is 2048 and it matters: it is
173    // part of what a sparse-MLA backend gates on.
174    if config.index_topk == 0
175        && let Some(v) = text.get("index_topk").and_then(|v| v.as_u64())
176    {
177        config.index_topk = v as usize;
178    }
179    // `index_kpool` sets the pool budget (`index_topk / index_kpool`), so a missing
180    // value is not a cosmetic default — it would silently change how many candidates
181    // the top-k ranks. Read it, never guess it.
182    if let Some(v) = text.get("index_kpool").and_then(|v| v.as_u64()) {
183        config.index_kpool = v as usize;
184    }
185    if let Some(v) = text
186        .get("index_kpool_always_select_tail")
187        .and_then(|v| v.as_bool())
188    {
189        config.index_kpool_always_select_tail = v;
190    }
191
192    // ---- Layer types ------------------------------------------------------
193    config.layer_types = build_layer_types(text, config.num_hidden_layers)?;
194
195    // MTP / NextN layers sit PAST the text stack. GLM-5.3-Flash declares
196    // `num_nextn_predict_layers = 1`, so layer 45 exists as a real decoder layer while
197    // `layer_types` legitimately covers only 0..=44. Give it its own slot rather than
198    // appending it, so every "iterate the text stack" loop keeps meaning what it says.
199    //
200    // The config does not state the MTP block's mixer kind. Derived here as "the same
201    // non-linear mixer this model uses", which the Slice-8 checkpoint audit confirms:
202    // layer 45's `self_attn` tensor set is name/dtype/shape IDENTICAL to the 11
203    // `deepseek_sparse_attention` text layers. Weight binding classifies from tensor
204    // names anyway and does not trust this field.
205    let n_mtp = text
206        .get("num_nextn_predict_layers")
207        .and_then(|v| v.as_u64())
208        .unwrap_or(0) as usize;
209    if n_mtp > 0 {
210        let kind = if config.layer_types.contains(&LayerType::SparseAttention) {
211            LayerType::SparseAttention
212        } else {
213            LayerType::FullAttention
214        };
215        config.mtp_layer_types = vec![kind; n_mtp];
216    }
217
218    // ---- Dense-vs-routed MLP split ---------------------------------------
219    // `first_k_dense_replace = 3`: layers 0..=2 carry a dense MLP, every later text layer
220    // routes to experts. Nothing in Atlas read this before, so `mlp_only_layers` came out
221    // EMPTY and the whole stack looked routed — a dense layer bound as MoE looks for
222    // `mlp.experts.*` that do not exist. Cross-checked against the textual
223    // `mlp_layer_types` array when the checkpoint carries one, the same way
224    // `build_layer_types` cross-checks the mixer map.
225    config.mlp_only_layers = build_mlp_only_layers(text, config.num_hidden_layers)?;
226
227    // ---- SwiGLU clamp -----------------------------------------------------
228    // 🔴 GLM clamps its SwiGLU and the clamp is ASYMMETRIC (`gate` upper-bounded only, `up`
229    // both ways). Nothing in Atlas read this before, so the value would have had to be
230    // hardcoded at a call site or defaulted to "no clamp" — and an absent clamp is INVISIBLE
231    // on well-scaled activations, firing only on the tails. Read it, never guess it.
232    config.swiglu_limit = match text.get("swiglu_limit").and_then(|v| v.as_f64()) {
233        Some(v) if v > 0.0 => v as f32,
234        Some(v) => bail!("glm5_next: swiglu_limit is {v}, which cannot bound anything"),
235        None => bail!(
236            "glm5_next config.json has no swiglu_limit; refusing to guess whether this \
237             checkpoint clamps its SwiGLU (GLM-5.3-Flash declares 10.0)"
238        ),
239    };
240
241    // ---- Grouped expert routing -------------------------------------------
242    // `n_group`/`topk_group` are 1 here, which makes the group mask all-ones and grouped
243    // routing a NO-OP. `glm5next_router_topk` implements plain top-k and takes `n_group`
244    // only to REFUSE a checkpoint where the machinery would matter. Refuse at parse time
245    // too, so the failure names the config rather than surfacing as a silent kernel return.
246    for key in ["n_group", "topk_group"] {
247        if let Some(v) = text.get(key).and_then(|v| v.as_u64())
248            && v != 1
249        {
250            bail!(
251                "glm5_next: {key} = {v}. Grouped expert routing is not implemented — \
252                 glm5next_router_topk ranks every expert in one group."
253            );
254        }
255    }
256
257    // ---- Router dtype ladder ---------------------------------------------
258    // 🔴 SEMANTIC, not precision. Default = HF 5.16.1's explicit fp32 router. The checkpoint may
259    // override with `moe_router_dtype` (the same field vLLM reads); an UNRECOGNISED value is a
260    // hard error, never a silent fallback onto the other semantics.
261    config.glm5next_router_mode = match text.get("moe_router_dtype").and_then(|v| v.as_str()) {
262        None => Glm5NextRouterMode::HfFp32,
263        Some(s) => Glm5NextRouterMode::from_config_str(s).ok_or_else(|| {
264            anyhow::anyhow!(
265                "glm5_next: unknown moe_router_dtype {s:?}; expected float32 or bfloat16"
266            )
267        })?,
268    };
269
270    // ---- Vision tower -----------------------------------------------------
271    // Read from the OUTER object: `vision_config` is a sibling of
272    // `text_config`, not a member of it. A `glm5_next_text` checkpoint has no
273    // outer wrapper and therefore no `vision_config` — it stays text-only,
274    // which is the behaviour every GLM serve had before this branch existed.
275    // Gated OFF by default — see `glm_vision_enabled`. When off this stays
276    // `None`, which is what it was before the tower was ported: the loader
277    // binds nothing and `msg_entry` refuses an image request with the existing
278    // "this model does not accept image or video input" error.
279    config.vision = match super::super::glm_vision_enabled() {
280        true => parse_glm5_next_vision(&raw),
281        false => None,
282    };
283
284    finalize_config(&mut config, &raw).context("glm5_next: finalize_config")?;
285    refuse_shared_indexer(text, &config).context("glm5_next: indexer_types")?;
286    validate_glm5_next(&config)?;
287    Ok(config)
288}
289
290/// GLM-5.3's `vision_config` → [`VisionConfig`].
291///
292/// Not `parsers::vision::parse_vision_config`: that one reads the Qwen3-VL
293/// field set and leaves everything GLM-specific at its default, which would
294/// hand the loader a tower with `rms_norm_eps = 1e-6`, no `swiglu_limit` and
295/// no merger width — three numbers that produce a running, wrong encoder
296/// rather than an error.
297///
298/// Every value is READ, never defaulted: a GLM checkpoint that omits one is a
299/// variant we have not seen, and guessing is how a vision tower comes out
300/// plausible and wrong.
301fn parse_glm5_next_vision(raw: &serde_json::Value) -> Option<super::super::VisionConfig> {
302    let vc = raw.get("vision_config")?;
303    let u = |k: &str| {
304        vc.get(k)
305            .and_then(serde_json::Value::as_u64)
306            .map(|v| v as usize)
307    };
308    let f = |k: &str| {
309        vc.get(k)
310            .and_then(serde_json::Value::as_f64)
311            .map(|v| v as f32)
312    };
313    // `rope_parameters = {rope_theta, rope_type: "axial"}`. Read live from the
314    // official checkpoint 2026-09-22; the transformers default is the same
315    // 10000.0, but reading beats inheriting.
316    let rope_theta = vc
317        .get("rope_parameters")
318        .and_then(|r| r.get("rope_theta"))
319        .and_then(serde_json::Value::as_f64)
320        .map(|v| v as f32)
321        .unwrap_or(10_000.0);
322    let stats = |k: &str, fallback: [f32; 3]| -> [f32; 3] {
323        let Some(arr) = vc.get(k).and_then(serde_json::Value::as_array) else {
324            return fallback;
325        };
326        let v: Vec<f32> = arr
327            .iter()
328            .filter_map(serde_json::Value::as_f64)
329            .map(|x| x as f32)
330            .collect();
331        match v.len() {
332            3 => [v[0], v[1], v[2]],
333            _ => fallback,
334        }
335    };
336    Some(super::super::VisionConfig {
337        depth: u("depth")?,
338        hidden_size: u("hidden_size")?,
339        num_heads: u("num_heads")?,
340        patch_size: u("patch_size")?,
341        temporal_patch_size: u("temporal_patch_size")?,
342        spatial_merge_size: u("spatial_merge_size")?,
343        intermediate_size: u("intermediate_size")?,
344        out_hidden_size: u("out_hidden_size")?,
345        // GLM's tower is single-scale: no deepstack list in the config and no
346        // deepstack merger in the checkpoint.
347        deepstack_visual_indexes: Vec::new(),
348        image_pad_token_id: raw
349            .get("image_token_id")
350            .and_then(serde_json::Value::as_u64)
351            .unwrap_or(0) as u32,
352        video_pad_token_id: raw
353            .get("video_token_id")
354            .and_then(serde_json::Value::as_u64)
355            .unwrap_or(0) as u32,
356        // Resolved by the server from the operator flag / preprocessor config.
357        max_pixels: None,
358        hidden_act: vc
359            .get("hidden_act")
360            .and_then(serde_json::Value::as_str)
361            .unwrap_or("silu")
362            .to_string(),
363        rms_norm_eps: f("rms_norm_eps").unwrap_or(1e-5),
364        attention_bias: vc
365            .get("attention_bias")
366            .and_then(serde_json::Value::as_bool)
367            .unwrap_or(true),
368        projection_intermediate_size: u("projection_intermediate_size")?,
369        swiglu_limit: f("swiglu_limit").unwrap_or(10.0),
370        rope_theta,
371        // CLIP-style stats, at the FULL precision `Glm5NextImageProcessor`
372        // carries as class defaults. They live in `processor_config.json`,
373        // which this parser does not see, so a default is unavoidable — and it
374        // must not be SigLIP's 0.5s, which is a ~2x scale error on every
375        // channel of every patch.
376        //
377        // 🪤 The checkpoint's `processor_config.json` prints these to 4 places
378        // (`0.4815`, `0.2686`, ...). Those are the SAME numbers rounded, but
379        // they are not equal: the reference's padded cells land on
380        // `-1.7922626` — `(0 - 0.48145466)/0.26862954` exactly — where the
381        // 4-place values give `-1.7926285`. The gap is 2.5% of one 1/255 pixel
382        // step, so nothing observable rides on it, but matching the reference
383        // bit-for-bit is free and a rounded constant that "looks right" is how
384        // a preprocessing drift hides. Verified against the golden
385        // `pixel_values` 2026-09-22.
386        // Written as the shortest decimals that round-trip through `f32` —
387        // the same values, in the only precision the type can hold.
388        image_mean: stats("image_mean", [0.481_454_67, 0.457_827_5, 0.408_210_72]),
389        image_std: stats("image_std", [0.268_629_55, 0.261_302_6, 0.275_777_1]),
390        min_image_tokens: u("min_image_tokens").unwrap_or(16),
391        max_image_tokens: u("max_image_tokens").unwrap_or(8000),
392        block_major_patches: true,
393    })
394}
395
396/// Layers whose MLP is dense rather than routed.
397///
398/// `first_k_dense_replace` is the authoritative knob; the textual `mlp_layer_types` array is
399/// used to CROSS-CHECK it, never as a silent substitute. A disagreement is a hard error: the
400/// two answers differing means the checkpoint is not the one this parser was written for.
401fn build_mlp_only_layers(text: &serde_json::Value, n_layers: usize) -> Result<Vec<usize>> {
402    let first_k = text
403        .get("first_k_dense_replace")
404        .and_then(|v| v.as_u64())
405        .map(|v| v as usize);
406    let textual: Option<Vec<usize>> =
407        text.get("mlp_layer_types")
408            .and_then(|v| v.as_array())
409            .map(|a| {
410                a.iter()
411                    .enumerate()
412                    .filter(|(_, v)| v.as_str() != Some("sparse"))
413                    .map(|(i, _)| i)
414                    .collect()
415            });
416
417    match (first_k, textual) {
418        (Some(k), t) => {
419            if k > n_layers {
420                bail!("glm5_next: first_k_dense_replace {k} exceeds num_hidden_layers {n_layers}");
421            }
422            let derived: Vec<usize> = (0..k).collect();
423            if let Some(t) = t
424                && t != derived
425            {
426                bail!(
427                    "glm5_next: first_k_dense_replace={k} implies dense layers \
428                     {derived:?}, but mlp_layer_types says {t:?}"
429                );
430            }
431            Ok(derived)
432        }
433        // No `first_k_dense_replace`: the textual array is then the only statement of the
434        // split, and it must be present and correctly sized.
435        (None, Some(t)) => Ok(t),
436        (None, None) => bail!(
437            "glm5_next: neither first_k_dense_replace nor mlp_layer_types present; \
438             refusing to guess which layers are dense"
439        ),
440    }
441}
442
443/// Build the per-layer mixer map.
444///
445/// Priority: the explicit `linear_attn_config.kda_layers` / `full_attn_layers`
446/// lists, cross-checked against `layer_types` when both are present. We do not
447/// fall back to `layer % 4 == 3` arithmetic — that pattern happens to hold for
448/// this checkpoint but is not stated anywhere as a contract.
449fn build_layer_types(text: &serde_json::Value, n_layers: usize) -> Result<Vec<LayerType>> {
450    let idx_list = |key: &str| -> Option<Vec<usize>> {
451        text.get("linear_attn_config")?
452            .get(key)?
453            .as_array()
454            .map(|a| {
455                a.iter()
456                    .filter_map(|v| v.as_u64())
457                    .map(|v| v as usize)
458                    .collect()
459            })
460    };
461
462    let kda = idx_list("kda_layers");
463    let full = idx_list("full_attn_layers");
464
465    let mut types = vec![LayerType::FullAttention; n_layers];
466    match (kda, full) {
467        (Some(kda), Some(full)) => {
468            if kda.len() + full.len() != n_layers {
469                bail!(
470                    "glm5_next: kda_layers ({}) + full_attn_layers ({}) != num_hidden_layers ({})",
471                    kda.len(),
472                    full.len(),
473                    n_layers
474                );
475            }
476            for i in &kda {
477                if *i >= n_layers {
478                    bail!("glm5_next: kda_layers index {i} out of range for {n_layers} layers");
479                }
480                types[*i] = LayerType::LinearAttention;
481            }
482            // `full_attn_layers` is GLM's name for "not KDA". On GLM-5.3-Flash those layers
483            // are `deepseek_sparse_attention`, so the textual array decides the variant —
484            // the index list alone cannot tell sparse from dense full attention.
485            let textual = text.get("layer_types").and_then(|v| v.as_array());
486            for i in &full {
487                if *i >= n_layers {
488                    bail!("glm5_next: full_attn_layers index {i} out of range");
489                }
490                if types[*i] == LayerType::LinearAttention {
491                    bail!("glm5_next: layer {i} listed as BOTH kda and full attention");
492                }
493                types[*i] = match textual.and_then(|a| a.get(*i)).and_then(|v| v.as_str()) {
494                    Some(GLM5NEXT_SPARSE_ATTN) => LayerType::SparseAttention,
495                    _ => LayerType::FullAttention,
496                };
497            }
498        }
499        _ => {
500            // Fall back to the textual `layer_types` array.
501            let arr = text
502                .get("layer_types")
503                .and_then(|v| v.as_array())
504                .context("glm5_next: neither linear_attn_config lists nor layer_types present")?;
505            if arr.len() != n_layers {
506                bail!(
507                    "glm5_next: layer_types has {} entries, expected {n_layers}",
508                    arr.len()
509                );
510            }
511            for (i, v) in arr.iter().enumerate() {
512                types[i] = match v.as_str().unwrap_or("") {
513                    "linear_attention" => LayerType::LinearAttention,
514                    GLM5NEXT_SPARSE_ATTN => LayerType::SparseAttention,
515                    "full_attention" => LayerType::FullAttention,
516                    other => bail!("glm5_next: unknown layer_type {other:?} at layer {i}"),
517                };
518            }
519        }
520    }
521
522    // Cross-check against layer_types when we used the index lists.
523    if let Some(arr) = text.get("layer_types").and_then(|v| v.as_array())
524        && arr.len() == n_layers
525    {
526        for (i, v) in arr.iter().enumerate() {
527            let want = match v.as_str().unwrap_or("") {
528                "linear_attention" => LayerType::LinearAttention,
529                GLM5NEXT_SPARSE_ATTN => LayerType::SparseAttention,
530                _ => LayerType::FullAttention,
531            };
532            if types[i] != want {
533                bail!(
534                    "glm5_next: layer {i} disagrees — index lists say {:?}, layer_types says {want:?}",
535                    types[i]
536                );
537            }
538        }
539    }
540    Ok(types)
541}
542
543/// Refuse a checkpoint whose DSA layers use **shared** indexing.
544///
545/// `indexer_types[i]` is `"full"` (this layer runs its own DSA indexer) or `"shared"`
546/// (it reuses the previous full layer's top-k selection, which HF propagates as
547/// `prev_topk_indices`). A shared layer must attend to the **upstream** layer's token
548/// set; running its own indexer instead yields a plausible wrong answer with no crash
549/// and no shape error, so this is refused at load rather than discovered in output.
550///
551/// `LibertAIDAI/GLM-5.3-Flash-NVFP4@9e0d74e3` carries an explicit 45-entry array that is
552/// entirely `"full"` — its 11 DSA layers (3, 7, … 43) each run their own indexer — so
553/// propagation is deliberately NOT implemented. Verified against the checkpoint config
554/// 2026-08-27.
555///
556/// Nothing is stored: the value of this check is the refusal. Adding a config field no
557/// runtime path reads would be dead surface.
558///
559/// When the array is absent, HF derives it, and so must we — an absent array does not
560/// mean all-full. Source: `transformers` 5.16.1 `configuration_glm5_next.py`:
561/// `index_topk_pattern` (an `"FSSF…"` string) wins, else
562/// `"full" if (max(i - offset + 1, 0) % freq) == 0` with `freq = max(index_topk_freq, 1)`
563/// and `offset = index_skip_topk_offset` (default 2).
564fn refuse_shared_indexer(text: &serde_json::Value, config: &ModelConfig) -> Result<()> {
565    let n = config.num_hidden_layers;
566    let modes: Vec<String> = if let Some(arr) = text.get("indexer_types").and_then(|v| v.as_array())
567    {
568        if arr.len() != n {
569            bail!(
570                "indexer_types has {} entries for {n} layers; a length mismatch would \
571                 silently misalign every layer's indexer mode",
572                arr.len()
573            );
574        }
575        arr.iter()
576            .map(|v| {
577                v.as_str()
578                    .map(str::to_string)
579                    .context("indexer_types entry is not a string")
580            })
581            .collect::<Result<_>>()?
582    } else if let Some(pat) = text.get("index_topk_pattern").and_then(|v| v.as_str()) {
583        if pat.chars().count() != n {
584            bail!(
585                "index_topk_pattern has {} chars for {n} layers",
586                pat.chars().count()
587            );
588        }
589        pat.chars()
590            .map(|c| match c {
591                'F' => Ok("full".to_string()),
592                'S' => Ok("shared".to_string()),
593                other => bail!("index_topk_pattern: unknown char {other:?}, expected F or S"),
594            })
595            .collect::<Result<_>>()?
596    } else {
597        let freq = text
598            .get("index_topk_freq")
599            .and_then(|v| v.as_u64())
600            .unwrap_or(1)
601            .max(1) as i64;
602        let offset = text
603            .get("index_skip_topk_offset")
604            .and_then(|v| v.as_i64())
605            .unwrap_or(2);
606        (0..n)
607            .map(|i| {
608                let shifted = ((i as i64) - offset + 1).max(0);
609                if shifted % freq == 0 {
610                    "full"
611                } else {
612                    "shared"
613                }
614                .to_string()
615            })
616            .collect()
617    };
618
619    for (i, m) in modes.iter().enumerate() {
620        if m != "full" && m != "shared" {
621            bail!("layer {i}: unknown indexer mode {m:?}, expected \"full\" or \"shared\"");
622        }
623    }
624
625    // Only DSA layers have an indexer at all; a "shared" entry on a KDA layer is inert.
626    let shared: Vec<usize> = config
627        .layer_types
628        .iter()
629        .enumerate()
630        .filter(|(i, t)| {
631            **t != LayerType::LinearAttention && modes.get(*i).is_some_and(|m| m == "shared")
632        })
633        .map(|(i, _)| i)
634        .collect();
635    if !shared.is_empty() {
636        bail!(
637            "DSA layer(s) {shared:?} use SHARED indexing (reuse the previous full layer's \
638             top-k). Atlas runs a per-layer indexer and does not propagate selections, so \
639             these layers would attend to the wrong token set — a wrong answer, not a \
640             crash. GLM-5.3-Flash-NVFP4 is entirely \"full\"; implement prev_topk_indices \
641             propagation before serving a checkpoint that is not."
642        );
643    }
644    Ok(())
645}
646
647fn validate_glm5_next(config: &ModelConfig) -> Result<()> {
648    if config.qk_rope_head_dim != 0 {
649        bail!(
650            "glm5_next: expected NoPE (qk_rope_head_dim == 0), got {}. \
651             A non-zero value means this is not the GLM-5.3 geometry we support.",
652            config.qk_rope_head_dim
653        );
654    }
655    if config.head_dim == 0 {
656        bail!("glm5_next: head_dim resolved to 0");
657    }
658    let linear = config
659        .layer_types
660        .iter()
661        .filter(|t| **t == LayerType::LinearAttention)
662        .count();
663    let full = config.layer_types.len() - linear;
664    if linear == 0 || full == 0 {
665        bail!("glm5_next: degenerate layer map — {linear} linear / {full} full");
666    }
667    Ok(())
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673
674    /// Trimmed from the real checkpoint config.json (LibertAI NVFP4 @ 9e0d74e3).
675    /// Values are verbatim; only unrelated keys were dropped for size.
676    fn glm53_config_json() -> String {
677        // KDA layers are every index NOT congruent to 3 (mod 4); the real file
678        // enumerates them explicitly and we mirror that here.
679        let kda: Vec<String> = (0..45)
680            .filter(|i| i % 4 != 3)
681            .map(|i| i.to_string())
682            .collect();
683        let full: Vec<String> = (0..45)
684            .filter(|i| i % 4 == 3)
685            .map(|i| i.to_string())
686            .collect();
687        let layer_types: Vec<String> = (0..45)
688            .map(|i| {
689                if i % 4 == 3 {
690                    "\"deepseek_sparse_attention\"".to_string()
691                } else {
692                    "\"linear_attention\"".to_string()
693                }
694            })
695            .collect();
696        format!(
697            r#"{{
698  "architectures": ["Glm5NextForConditionalGeneration"],
699  "model_type": "glm5_next",
700  "text_config": {{
701    "model_type": "glm5_next_text",
702    "num_hidden_layers": 45,
703    "num_nextn_predict_layers": 1,
704    "hidden_size": 4096,
705    "intermediate_size": 12288,
706    "num_attention_heads": 64,
707    "num_key_value_heads": 64,
708    "head_dim": 0,
709    "qk_head_dim": 256,
710    "qk_nope_head_dim": 256,
711    "qk_rope_head_dim": 0,
712    "v_head_dim": 256,
713    "kv_lora_rank": 512,
714    "q_lora_rank": 1536,
715    "mla_use_nope": true,
716    "index_topk": 2048,
717    "index_kpool": 4,
718    "index_n_heads": 32,
719    "index_head_dim": 128,
720    "hc_mult": 4,
721    "hc_sinkhorn_iters": 20,
722    "hc_eps": 1e-06,
723    "mhc": true,
724    "n_routed_experts": 288,
725    "n_shared_experts": 1,
726    "num_experts_per_tok": 8,
727    "moe_intermediate_size": 2048,
728    "first_k_dense_replace": 3,
729    "swiglu_limit": 10.0,
730    "routed_scaling_factor": 2.5,
731    "norm_topk_prob": true,
732    "n_group": 1,
733    "topk_group": 1,
734    "scoring_func": "sigmoid",
735    "topk_method": "noaux_tc",
736    "rms_norm_eps": 1e-05,
737    "vocab_size": 154880,
738    "max_position_embeddings": 1048576,
739    "linear_attn_config": {{
740      "num_heads": 64,
741      "head_dim": 128,
742      "short_conv_kernel_size": 4,
743      "gate_lower_bound": -5.0,
744      "kda_layers": [{kda}],
745      "full_attn_layers": [{full}]
746    }},
747    "layer_types": [{lt}]
748  }}
749}}"#,
750            kda = kda.join(","),
751            full = full.join(","),
752            lt = layer_types.join(",")
753        )
754    }
755
756    /// Inject a key into `text_config` and re-serialise, so a test can vary one
757    /// checkpoint field without restating the whole fixture.
758    fn with_text_key(key: &str, value: serde_json::Value) -> String {
759        let mut raw: serde_json::Value =
760            serde_json::from_str(&glm53_config_json()).expect("fixture json");
761        raw["text_config"][key] = value;
762        raw.to_string()
763    }
764
765    /// The real checkpoint's array: 45 entries, every one `"full"`.
766    #[test]
767    fn an_all_full_indexer_array_is_accepted() {
768        let all_full = serde_json::Value::from(vec!["full"; 45]);
769        let c = parse_glm5_next(&with_text_key("indexer_types", all_full)).expect("parse");
770        // The 11 DSA layers of GLM-5.3-Flash.
771        let dsa: Vec<usize> = c
772            .layer_types
773            .iter()
774            .enumerate()
775            .filter(|(_, t)| **t != LayerType::LinearAttention)
776            .map(|(i, _)| i)
777            .collect();
778        assert_eq!(dsa, vec![3, 7, 11, 15, 19, 23, 27, 31, 35, 39, 43]);
779    }
780
781    /// 🔴 A shared DSA layer must attend to the UPSTREAM layer's token set. Atlas runs a
782    /// per-layer indexer and does not propagate, so this is a wrong answer with no crash
783    /// — refused at load.
784    #[test]
785    fn a_shared_dsa_layer_is_refused() {
786        let mut modes = vec!["full"; 45];
787        modes[7] = "shared"; // a real DSA layer
788        let e = parse_glm5_next(&with_text_key("indexer_types", modes.into()))
789            .expect_err("shared DSA indexing must be refused");
790        let msg = e.to_string() + &e.root_cause().to_string();
791        assert!(msg.contains('7'), "the error must name the layer: {msg}");
792    }
793
794    /// A `"shared"` entry on a KDA layer is inert — those layers have no indexer at all.
795    /// Refusing it would reject a legal checkpoint.
796    #[test]
797    fn a_shared_entry_on_a_linear_layer_is_inert() {
798        let mut modes = vec!["full"; 45];
799        modes[0] = "shared"; // layer 0 is KDA
800        assert!(parse_glm5_next(&with_text_key("indexer_types", modes.into())).is_ok());
801    }
802
803    /// A length mismatch would silently misalign every layer's mode.
804    #[test]
805    fn a_wrong_length_indexer_array_is_refused() {
806        let short = serde_json::Value::from(vec!["full"; 44]);
807        assert!(parse_glm5_next(&with_text_key("indexer_types", short)).is_err());
808    }
809
810    /// An ABSENT array does not mean all-full: HF derives it. With the default
811    /// `freq = 1` every layer is full, which is why the bare fixture parses — but a
812    /// `freq = 4` schedule genuinely produces shared DSA layers and must be refused.
813    #[test]
814    fn an_absent_array_is_derived_not_assumed_full() {
815        // freq=1 (the default) → all full → parses.
816        assert!(parse_glm5_next(&glm53_config_json()).is_ok());
817        // freq=4, offset=2 → full only where (max(i-1,0) % 4 == 0); DSA layer 7 is shared.
818        let e = parse_glm5_next(&with_text_key("index_topk_freq", 4.into()))
819            .expect_err("a freq schedule that shares DSA layers must be refused");
820        assert!(e.root_cause().to_string().contains("SHARED"), "{e}");
821    }
822
823    /// The `"FSSF…"` pattern string overrides the freq schedule, as in HF.
824    #[test]
825    fn an_index_topk_pattern_is_honoured() {
826        // All-F pattern parses; one S on a DSA layer does not.
827        let ok: String = "F".repeat(45);
828        assert!(parse_glm5_next(&with_text_key("index_topk_pattern", ok.into())).is_ok());
829        let mut bad: Vec<char> = "F".repeat(45).chars().collect();
830        bad[43] = 'S'; // the last DSA layer
831        let bad: String = bad.into_iter().collect();
832        assert!(parse_glm5_next(&with_text_key("index_topk_pattern", bad.into())).is_err());
833    }
834
835    #[test]
836    fn parses_glm5_next() {
837        let c = parse_glm5_next(&glm53_config_json()).expect("parse");
838        assert_eq!(c.model_type, "glm5_next");
839        assert_eq!(c.num_hidden_layers, 45);
840        assert_eq!(c.hidden_size, 4096);
841        assert_eq!(c.n_routed_experts, 288);
842        assert_eq!(c.num_experts_per_tok, 8);
843        assert_eq!(c.moe_intermediate_size, 2048);
844    }
845
846    /// The acceptance criterion: a legitimate zero must round-trip untouched.
847    #[test]
848    fn nope_rope_dim_zero_survives_exactly() {
849        let c = parse_glm5_next(&glm53_config_json()).expect("parse");
850        assert_eq!(c.qk_rope_head_dim, 0, "NoPE zero must not be 'repaired'");
851        assert_eq!(c.qk_nope_head_dim, 256, "nope dim must come from the file");
852        assert_eq!(c.v_head_dim, 256);
853        assert_eq!(c.partial_rotary_factor, 0.0);
854    }
855
856    /// 🪤 GLM-5.3 is **MLA AND NoPE at the same time** — the exact combination
857    /// that broke Atlas's MLA decode dispatch.
858    ///
859    /// `qwen3_attention/decode/run_paged_decode.rs` used to select the MLA
860    /// compressed-cache decode with `mla.rope > 0`, treating "has a RoPE
861    /// section" as a proxy for "is MLA". That holds for DeepSeek-V4-Flash
862    /// (rope=64) and fails here: GLM has a 512-dim latent cache and rope=0, so
863    /// it satisfied "is MLA" while failing the proxy, and would have fallen
864    /// through to the generic *non-MLA* paged decode carrying an MLA-shaped
865    /// cache — a wrong answer with no crash.
866    ///
867    /// The predicate is now `mla.is_some()`, and only the genuine RoPE
868    /// operations are guarded on `rope > 0`. This test pins the config-level
869    /// fact that makes the distinction necessary; if it ever fails, re-read that
870    /// dispatch before touching anything else.
871    #[test]
872    fn is_mla_and_nope_simultaneously() {
873        let c = parse_glm5_next(&glm53_config_json()).expect("parse");
874        assert!(
875            c.kv_lora_rank > 0,
876            "GLM-5.3 is MLA: a latent KV cache of {} dims",
877            c.kv_lora_rank
878        );
879        assert_eq!(c.kv_lora_rank, 512);
880        assert_eq!(
881            c.qk_rope_head_dim, 0,
882            "...and simultaneously NoPE. `rope > 0` must never stand in for `is MLA`."
883        );
884    }
885
886    /// head_dim=0 in-file must resolve to qk_head_dim (256), never to
887    /// hidden_size / num_attention_heads (64).
888    #[test]
889    fn head_dim_resolves_to_mla_width_not_hidden_over_heads() {
890        let c = parse_glm5_next(&glm53_config_json()).expect("parse");
891        assert_eq!(c.head_dim, 256);
892        assert_ne!(c.head_dim, 4096 / 64);
893    }
894
895    /// Reconciled census, TEXT LAYERS ONLY (0..44): 34 KDA + 11 DSA.
896    /// See .planning/AVAROK-GLM5NEXT-SKILL-RECONCILIATION-20260826.md — the
897    /// whole-checkpoint totals differ because layer 45 (MTP) is DSA-shaped.
898    #[test]
899    fn layer_census_matches_reconciled_counts() {
900        let c = parse_glm5_next(&glm53_config_json()).expect("parse");
901        let kda = c
902            .layer_types
903            .iter()
904            .filter(|t| **t == LayerType::LinearAttention)
905            .count();
906        // Since Slice 8 the DSA layers are `SparseAttention`, not `FullAttention` — and
907        // there must be ZERO plain full-attention layers, or something was flattened.
908        let dsa = c
909            .layer_types
910            .iter()
911            .filter(|t| **t == LayerType::SparseAttention)
912            .count();
913        let plain_full = c
914            .layer_types
915            .iter()
916            .filter(|t| **t == LayerType::FullAttention)
917            .count();
918        assert_eq!(c.layer_types.len(), 45);
919        assert_eq!(kda, 34, "KDA layers over text layers 0..44");
920        assert_eq!(dsa, 11, "DSA layers over text layers 0..44");
921        assert_eq!(plain_full, 0, "GLM-5.3 has no plain full-attention layer");
922        // Spot-check the actual indices, not just the totals.
923        assert_eq!(c.layer_types[0], LayerType::LinearAttention);
924        assert_eq!(c.layer_types[3], LayerType::SparseAttention);
925        assert_eq!(c.layer_types[43], LayerType::SparseAttention);
926        assert_eq!(c.layer_types[44], LayerType::LinearAttention);
927    }
928
929    /// `layer_types` must round-trip back to the checkpoint's own vocabulary. This is what
930    /// "flattened onto FullAttention" used to break: the parse succeeded and the array
931    /// silently said `full_attention` where the checkpoint said `deepseek_sparse_attention`.
932    #[test]
933    fn layer_types_round_trip_to_the_checkpoint_strings() {
934        let c = parse_glm5_next(&glm53_config_json()).expect("parse");
935        let raw: serde_json::Value = serde_json::from_str(&glm53_config_json()).unwrap();
936        let want = raw["text_config"]["layer_types"]
937            .as_array()
938            .expect("layer_types");
939        assert_eq!(want.len(), c.layer_types.len());
940        for (i, w) in want.iter().enumerate() {
941            assert_eq!(
942                c.layer_types[i].hf_name(),
943                w.as_str().unwrap(),
944                "layer {i} does not round-trip"
945            );
946        }
947    }
948
949    /// Layer 45 (MTP) is a real decoder layer that is NOT part of the text stack.
950    /// It must be representable without being appended to `layer_types`.
951    #[test]
952    fn mtp_layer_is_represented_outside_the_text_stack() {
953        let c = parse_glm5_next(&glm53_config_json()).expect("parse");
954        assert_eq!(c.num_hidden_layers, 45);
955        assert_eq!(c.layer_types.len(), 45, "text stack stays 0..=44");
956        assert_eq!(c.mtp_layer_types, vec![LayerType::SparseAttention]);
957        // Index 45 resolves, and it resolves through the MTP list, not the text stack.
958        assert_eq!(c.layer_type_at(45), Some(LayerType::SparseAttention));
959        assert_eq!(c.layer_type_at(46), None);
960        assert!(c.has_sparse_attention());
961        assert_eq!(c.sparse_attention_layers().len(), 11, "text stack only");
962    }
963
964    #[test]
965    fn kda_geometry_from_linear_attn_config() {
966        let c = parse_glm5_next(&glm53_config_json()).expect("parse");
967        assert_eq!(c.linear_num_key_heads, 64);
968        assert_eq!(c.linear_key_head_dim, 128);
969        assert_eq!(c.linear_conv_kernel_dim, 4);
970    }
971
972    #[test]
973    fn indexer_topk_is_2048_not_the_deepseek_default() {
974        let c = parse_glm5_next(&glm53_config_json()).expect("parse");
975        assert_eq!(c.index_topk, 2048);
976    }
977
978    #[test]
979    fn missing_rope_key_is_refused_not_guessed() {
980        let mut v: serde_json::Value = serde_json::from_str(&glm53_config_json()).unwrap();
981        v["text_config"]
982            .as_object_mut()
983            .unwrap()
984            .remove("qk_rope_head_dim");
985        let err = parse_glm5_next(&v.to_string()).unwrap_err();
986        assert!(
987            err.to_string().contains("refusing to guess"),
988            "unexpected error: {err}"
989        );
990    }
991
992    /// GLM's SwiGLU clamp is asymmetric and fires only on the activation tails, so a
993    /// checkpoint that declares it and a parser that defaults it disagree SILENTLY on
994    /// well-scaled inputs. The parser must refuse rather than fall back to "no clamp".
995    #[test]
996    fn a_missing_swiglu_limit_is_refused_not_defaulted() {
997        let mut v: serde_json::Value = serde_json::from_str(&glm53_config_json()).unwrap();
998        v["text_config"]
999            .as_object_mut()
1000            .unwrap()
1001            .remove("swiglu_limit");
1002        let err = parse_glm5_next(&v.to_string()).unwrap_err();
1003        assert!(
1004            err.to_string().contains("swiglu_limit"),
1005            "unexpected error: {err}"
1006        );
1007    }
1008
1009    #[test]
1010    fn the_swiglu_limit_is_read_verbatim() {
1011        let c = parse_glm5_next(&glm53_config_json()).unwrap();
1012        assert_eq!(c.swiglu_limit, 10.0);
1013    }
1014
1015    /// `n_group`/`topk_group` > 1 would make the group mask load-bearing, and
1016    /// `glm5next_router_topk` returns without writing rather than implementing it. Refuse at
1017    /// parse time so the failure names the config, not an empty selection row.
1018    #[test]
1019    fn grouped_expert_routing_is_refused() {
1020        for key in ["n_group", "topk_group"] {
1021            let mut v: serde_json::Value = serde_json::from_str(&glm53_config_json()).unwrap();
1022            v["text_config"][key] = serde_json::json!(8);
1023            let err = parse_glm5_next(&v.to_string()).unwrap_err();
1024            assert!(
1025                err.to_string().contains("Grouped expert routing"),
1026                "{key}: unexpected error: {err}"
1027            );
1028        }
1029    }
1030
1031    #[test]
1032    fn contradictory_layer_maps_are_rejected() {
1033        let mut v: serde_json::Value = serde_json::from_str(&glm53_config_json()).unwrap();
1034        // Claim layer 0 is full attention in layer_types while the index list
1035        // says KDA — must not be silently resolved.
1036        v["text_config"]["layer_types"][0] =
1037            serde_json::Value::String("deepseek_sparse_attention".into());
1038        let err = parse_glm5_next(&v.to_string()).unwrap_err();
1039        assert!(err.to_string().contains("disagrees"), "unexpected: {err}");
1040    }
1041
1042    // ───────────────────────────────────────────── GLM router dtype ladder (Slice 10)
1043
1044    /// 🔴 The production default is HF's fp32 router. An absent `moe_router_dtype` means "the
1045    /// checkpoint did not say", and the reference implementation's answer for that is fp32 — the
1046    /// OPPOSITE of vLLM's fallthrough to bf16.
1047    #[test]
1048    fn glm_router_defaults_to_hf_fp32_when_the_config_is_silent() {
1049        let cfg = parse_glm5_next(&glm53_config_json()).unwrap();
1050        assert_eq!(cfg.glm5next_router_mode, Glm5NextRouterMode::HfFp32);
1051        assert!(cfg.glm5next_router_mode.is_fp32());
1052    }
1053
1054    /// The compatibility mode must be reachable EXPLICITLY, from its own field — never inferred
1055    /// from quantization settings or from the weight-storage precision schedule.
1056    #[test]
1057    fn glm_router_bf16_compat_mode_is_explicit_and_never_inferred() {
1058        let base = glm53_config_json();
1059        let with = base.replace(
1060            r#""hc_mult": 4,"#,
1061            r#""hc_mult": 4, "moe_router_dtype": "bfloat16","#,
1062        );
1063        assert_ne!(with, base, "fixture anchor moved");
1064        let cfg = parse_glm5_next(&with).unwrap();
1065        assert_eq!(cfg.glm5next_router_mode, Glm5NextRouterMode::VllmBf16);
1066        assert!(!cfg.glm5next_router_mode.is_fp32());
1067
1068        let fp32 = base.replace(
1069            r#""hc_mult": 4,"#,
1070            r#""hc_mult": 4, "moe_router_dtype": "float32","#,
1071        );
1072        assert_eq!(
1073            parse_glm5_next(&fp32).unwrap().glm5next_router_mode,
1074            Glm5NextRouterMode::HfFp32
1075        );
1076    }
1077
1078    /// An unrecognised value must be a hard error. Silently falling back would pick one of two
1079    /// semantics at random, which is the whole defect this switch exists to prevent.
1080    #[test]
1081    fn an_unknown_router_dtype_is_refused_not_defaulted() {
1082        let bad = glm53_config_json().replace(
1083            r#""hc_mult": 4,"#,
1084            r#""hc_mult": 4, "moe_router_dtype": "fp8","#,
1085        );
1086        assert!(parse_glm5_next(&bad).is_err());
1087    }
1088}