metrale_config/parsers/
glm5_next.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: `config.json` parser for GLM-5.3-Flash (`model_type` `glm5_next` or
4//! `glm5_next_text`): NoPE MLA (`qk_rope_head_dim == 0`) over a stack of KDA linear-attention
5//! and DeepSeek sparse-attention (DSA) layers.
6//!
7//! The DeepSeek-V4 parser is not reused: it derives `qk_nope_head_dim` from
8//! `head_dim - qk_rope_head_dim` when the rope dim is non-zero, and here a zero rope dim is a
9//! value to keep.
10//!
11//! Owner: config (model parsers).
12//! Invariants:
13//! - A parse that succeeds has `qk_rope_head_dim == 0`, a non-zero `head_dim`, and at least
14//!   one linear and one non-linear text layer (`validate_glm5_next`).
15//! - When `linear_attn_config` has both index lists, they decide the layer map, and a
16//!   `layer_types` array with one entry per layer must agree with it (`build_layer_types`).
17//! - A DSA layer with shared indexing is refused (`refuse_shared_indexer`).
18
19use anyhow::{Context, Result, bail};
20
21use super::super::{Glm5NextRouterMode, LayerType, ModelConfig, finalize_config};
22mod parse;
23pub use parse::parse_glm5_next;
24
25/// 2026-09-26: The layer index of the MTP layer: `num_hidden_layers`, one past the text
26/// stack, which `num_hidden_layers` counts alone.
27pub fn glm5_next_mtp_layer_index(config: &ModelConfig) -> usize {
28    config.num_hidden_layers
29}
30
31fn text_config(raw: &serde_json::Value) -> &serde_json::Value {
32    raw.get("text_config").unwrap_or(raw)
33}
34
35/// 2026-09-26: GLM's `layer_types` name for its sparse-MLA mixer. It parses to
36/// [`LayerType::SparseAttention`], whose `hf_name()` returns this string.
37pub const GLM5NEXT_SPARSE_ATTN: &str = "deepseek_sparse_attention";
38
39/// 2026-09-26: GLM-5.3's `vision_config` as a `VisionConfig`.
40///
41/// `parse_vision_config` is not used: it leaves the GLM-specific fields at
42/// `VisionConfig::default()`, i.e. `rms_norm_eps = 1e-6`, `swiglu_limit = 0` and no merger
43/// width.
44///
45/// Returns `None` when a tower dimension or `projection_intermediate_size` is missing. The
46/// other fields fall back to the constants below when absent.
47fn parse_glm5_next_vision(raw: &serde_json::Value) -> Option<super::super::VisionConfig> {
48    let vc = raw.get("vision_config")?;
49    let u = |k: &str| {
50        vc.get(k)
51            .and_then(serde_json::Value::as_u64)
52            .map(|v| v as usize)
53    };
54    let f = |k: &str| {
55        vc.get(k)
56            .and_then(serde_json::Value::as_f64)
57            .map(|v| v as f32)
58    };
59    let rope_theta = vc
60        .get("rope_parameters")
61        .and_then(|r| r.get("rope_theta"))
62        .and_then(serde_json::Value::as_f64)
63        .map(|v| v as f32)
64        .unwrap_or(10_000.0);
65    let stats = |k: &str, fallback: [f32; 3]| -> [f32; 3] {
66        let Some(arr) = vc.get(k).and_then(serde_json::Value::as_array) else {
67            return fallback;
68        };
69        let v: Vec<f32> = arr
70            .iter()
71            .filter_map(serde_json::Value::as_f64)
72            .map(|x| x as f32)
73            .collect();
74        match v.len() {
75            3 => [v[0], v[1], v[2]],
76            _ => fallback,
77        }
78    };
79    Some(super::super::VisionConfig {
80        depth: u("depth")?,
81        hidden_size: u("hidden_size")?,
82        num_heads: u("num_heads")?,
83        patch_size: u("patch_size")?,
84        temporal_patch_size: u("temporal_patch_size")?,
85        spatial_merge_size: u("spatial_merge_size")?,
86        intermediate_size: u("intermediate_size")?,
87        out_hidden_size: u("out_hidden_size")?,
88        deepstack_visual_indexes: Vec::new(),
89        image_pad_token_id: raw
90            .get("image_token_id")
91            .and_then(serde_json::Value::as_u64)
92            .unwrap_or(0) as u32,
93        video_pad_token_id: raw
94            .get("video_token_id")
95            .and_then(serde_json::Value::as_u64)
96            .unwrap_or(0) as u32,
97        // 2026-09-26: Filled by the server before the encoder is built, as in
98        // `parse_vision_config`.
99        max_pixels: None,
100        hidden_act: vc
101            .get("hidden_act")
102            .and_then(serde_json::Value::as_str)
103            .unwrap_or("silu")
104            .to_string(),
105        rms_norm_eps: f("rms_norm_eps").unwrap_or(1e-5),
106        attention_bias: vc
107            .get("attention_bias")
108            .and_then(serde_json::Value::as_bool)
109            .unwrap_or(true),
110        projection_intermediate_size: u("projection_intermediate_size")?,
111        swiglu_limit: f("swiglu_limit").unwrap_or(10.0),
112        rope_theta,
113        // 2026-09-26: The fallback mean and std are 0.48145466, 0.4578275, 0.40821073 and
114        // 0.26862954, 0.26130258, 0.27577711, written as the shortest decimals that give the
115        // same `f32`. `VisionConfig::default()` holds 0.5 on every channel.
116        image_mean: stats("image_mean", [0.481_454_67, 0.457_827_5, 0.408_210_72]),
117        image_std: stats("image_std", [0.268_629_55, 0.261_302_6, 0.275_777_1]),
118        min_image_tokens: u("min_image_tokens").unwrap_or(16),
119        max_image_tokens: u("max_image_tokens").unwrap_or(8000),
120        block_major_patches: true,
121    })
122}
123
124/// 2026-09-26: The layers whose MLP is dense rather than routed.
125///
126/// With `first_k_dense_replace = k` the answer is layers `0..k`, and a `mlp_layer_types` array
127/// that disagrees is an error. Without it, the indices of the array's non-`"sparse"` entries
128/// are used. With neither, this is an error.
129fn build_mlp_only_layers(text: &serde_json::Value, n_layers: usize) -> Result<Vec<usize>> {
130    let first_k = text
131        .get("first_k_dense_replace")
132        .and_then(|v| v.as_u64())
133        .map(|v| v as usize);
134    let textual: Option<Vec<usize>> =
135        text.get("mlp_layer_types")
136            .and_then(|v| v.as_array())
137            .map(|a| {
138                a.iter()
139                    .enumerate()
140                    .filter(|(_, v)| v.as_str() != Some("sparse"))
141                    .map(|(i, _)| i)
142                    .collect()
143            });
144
145    match (first_k, textual) {
146        (Some(k), t) => {
147            if k > n_layers {
148                bail!("glm5_next: first_k_dense_replace {k} exceeds num_hidden_layers {n_layers}");
149            }
150            let derived: Vec<usize> = (0..k).collect();
151            if let Some(t) = t
152                && t != derived
153            {
154                bail!(
155                    "glm5_next: first_k_dense_replace={k} implies dense layers \
156                     {derived:?}, but mlp_layer_types says {t:?}"
157                );
158            }
159            Ok(derived)
160        }
161        (None, Some(t)) => Ok(t),
162        (None, None) => bail!(
163            "glm5_next: neither first_k_dense_replace nor mlp_layer_types present; \
164             refusing to guess which layers are dense"
165        ),
166    }
167}
168
169/// 2026-09-26: The per-layer mixer map.
170///
171/// When `linear_attn_config` has both `kda_layers` and `full_attn_layers`, they decide it,
172/// and a `layer_types` array with `n_layers` entries must agree. Otherwise `layer_types`
173/// decides it alone. No index arithmetic (such as `layer % 4 == 3`) is assumed.
174fn build_layer_types(text: &serde_json::Value, n_layers: usize) -> Result<Vec<LayerType>> {
175    let idx_list = |key: &str| -> Option<Vec<usize>> {
176        text.get("linear_attn_config")?
177            .get(key)?
178            .as_array()
179            .map(|a| {
180                a.iter()
181                    .filter_map(|v| v.as_u64())
182                    .map(|v| v as usize)
183                    .collect()
184            })
185    };
186
187    let kda = idx_list("kda_layers");
188    let full = idx_list("full_attn_layers");
189
190    let mut types = vec![LayerType::FullAttention; n_layers];
191    match (kda, full) {
192        (Some(kda), Some(full)) => {
193            if kda.len() + full.len() != n_layers {
194                bail!(
195                    "glm5_next: kda_layers ({}) + full_attn_layers ({}) != num_hidden_layers ({})",
196                    kda.len(),
197                    full.len(),
198                    n_layers
199                );
200            }
201            for i in &kda {
202                if *i >= n_layers {
203                    bail!("glm5_next: kda_layers index {i} out of range for {n_layers} layers");
204                }
205                types[*i] = LayerType::LinearAttention;
206            }
207            // 2026-09-26: `full_attn_layers` lists the non-KDA layers; the index list cannot
208            // tell sparse from dense attention, so the textual array decides the variant.
209            let textual = text.get("layer_types").and_then(|v| v.as_array());
210            for i in &full {
211                if *i >= n_layers {
212                    bail!("glm5_next: full_attn_layers index {i} out of range");
213                }
214                if types[*i] == LayerType::LinearAttention {
215                    bail!("glm5_next: layer {i} listed as BOTH kda and full attention");
216                }
217                types[*i] = match textual.and_then(|a| a.get(*i)).and_then(|v| v.as_str()) {
218                    Some(GLM5NEXT_SPARSE_ATTN) => LayerType::SparseAttention,
219                    _ => LayerType::FullAttention,
220                };
221            }
222        }
223        _ => {
224            let arr = text
225                .get("layer_types")
226                .and_then(|v| v.as_array())
227                .context("glm5_next: neither linear_attn_config lists nor layer_types present")?;
228            if arr.len() != n_layers {
229                bail!(
230                    "glm5_next: layer_types has {} entries, expected {n_layers}",
231                    arr.len()
232                );
233            }
234            for (i, v) in arr.iter().enumerate() {
235                types[i] = match v.as_str().unwrap_or("") {
236                    "linear_attention" => LayerType::LinearAttention,
237                    GLM5NEXT_SPARSE_ATTN => LayerType::SparseAttention,
238                    "full_attention" => LayerType::FullAttention,
239                    other => bail!("glm5_next: unknown layer_type {other:?} at layer {i}"),
240                };
241            }
242        }
243    }
244
245    if let Some(arr) = text.get("layer_types").and_then(|v| v.as_array())
246        && arr.len() == n_layers
247    {
248        for (i, v) in arr.iter().enumerate() {
249            let want = match v.as_str().unwrap_or("") {
250                "linear_attention" => LayerType::LinearAttention,
251                GLM5NEXT_SPARSE_ATTN => LayerType::SparseAttention,
252                _ => LayerType::FullAttention,
253            };
254            if types[i] != want {
255                bail!(
256                    "glm5_next: layer {i} disagrees — index lists say {:?}, layer_types says {want:?}",
257                    types[i]
258                );
259            }
260        }
261    }
262    Ok(types)
263}
264
265/// 2026-09-26: Refuse a config whose DSA layers use shared indexing.
266///
267/// `indexer_types[i]` is `"full"` (the layer runs its own DSA indexer) or `"shared"` (it
268/// reuses the previous full layer's top-k selection). The engine runs a per-layer indexer
269/// and does not propagate selections, so a shared DSA layer would attend to the wrong token
270/// set without any error; it is refused here. A `"shared"` entry on a KDA layer is ignored.
271/// Nothing is stored in the config.
272///
273/// Without `indexer_types`, the modes come from `index_topk_pattern` (one `F` or `S` per
274/// layer), else layer `i` is full when `max(i - offset + 1, 0) % freq == 0`, with
275/// `freq = max(index_topk_freq, 1)` (default 1) and `offset = index_skip_topk_offset`
276/// (default 2).
277fn refuse_shared_indexer(text: &serde_json::Value, config: &ModelConfig) -> Result<()> {
278    let n = config.num_hidden_layers;
279    let modes: Vec<String> = if let Some(arr) = text.get("indexer_types").and_then(|v| v.as_array())
280    {
281        if arr.len() != n {
282            bail!(
283                "indexer_types has {} entries for {n} layers; a length mismatch would \
284                 silently misalign every layer's indexer mode",
285                arr.len()
286            );
287        }
288        arr.iter()
289            .map(|v| {
290                v.as_str()
291                    .map(str::to_string)
292                    .context("indexer_types entry is not a string")
293            })
294            .collect::<Result<_>>()?
295    } else if let Some(pat) = text.get("index_topk_pattern").and_then(|v| v.as_str()) {
296        if pat.chars().count() != n {
297            bail!(
298                "index_topk_pattern has {} chars for {n} layers",
299                pat.chars().count()
300            );
301        }
302        pat.chars()
303            .map(|c| match c {
304                'F' => Ok("full".to_string()),
305                'S' => Ok("shared".to_string()),
306                other => bail!("index_topk_pattern: unknown char {other:?}, expected F or S"),
307            })
308            .collect::<Result<_>>()?
309    } else {
310        let freq = text
311            .get("index_topk_freq")
312            .and_then(|v| v.as_u64())
313            .unwrap_or(1)
314            .max(1) as i64;
315        let offset = text
316            .get("index_skip_topk_offset")
317            .and_then(|v| v.as_i64())
318            .unwrap_or(2);
319        (0..n)
320            .map(|i| {
321                let shifted = ((i as i64) - offset + 1).max(0);
322                if shifted % freq == 0 {
323                    "full"
324                } else {
325                    "shared"
326                }
327                .to_string()
328            })
329            .collect()
330    };
331
332    for (i, m) in modes.iter().enumerate() {
333        if m != "full" && m != "shared" {
334            bail!("layer {i}: unknown indexer mode {m:?}, expected \"full\" or \"shared\"");
335        }
336    }
337
338    let shared: Vec<usize> = config
339        .layer_types
340        .iter()
341        .enumerate()
342        .filter(|(i, t)| {
343            **t != LayerType::LinearAttention && modes.get(*i).is_some_and(|m| m == "shared")
344        })
345        .map(|(i, _)| i)
346        .collect();
347    if !shared.is_empty() {
348        bail!(
349            "DSA layer(s) {shared:?} use SHARED indexing (reuse the previous full layer's \
350             top-k). Metrale Engine runs a per-layer indexer and does not propagate selections, so \
351             these layers would attend to the wrong token set — a wrong answer, not a \
352             crash. GLM-5.3-Flash-NVFP4 is entirely \"full\"; implement prev_topk_indices \
353             propagation before serving a checkpoint that is not."
354        );
355    }
356    Ok(())
357}
358
359fn validate_glm5_next(config: &ModelConfig) -> Result<()> {
360    if config.qk_rope_head_dim != 0 {
361        bail!(
362            "glm5_next: expected NoPE (qk_rope_head_dim == 0), got {}. \
363             A non-zero value means this is not the GLM-5.3 geometry we support.",
364            config.qk_rope_head_dim
365        );
366    }
367    if config.head_dim == 0 {
368        bail!("glm5_next: head_dim resolved to 0");
369    }
370    let linear = config
371        .layer_types
372        .iter()
373        .filter(|t| **t == LayerType::LinearAttention)
374        .count();
375    let full = config.layer_types.len() - linear;
376    if linear == 0 || full == 0 {
377        bail!("glm5_next: degenerate layer map — {linear} linear / {full} full");
378    }
379    Ok(())
380}
381
382#[cfg(test)]
383mod tests;