metrale_model_arch/
glm5next_skeleton.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: GLM-5.3-Flash text-model skeleton: topology, residual/norm/mHC wiring, structural weight contract and state plan, as data.
4//!
5//! Owner: model-arch (GLM-5.3).
6//! Invariants:
7//! - A skeleton from `from_config` has one `layers` entry per `layer_types` entry, each with
8//!   `hyper_connection` set and `is_mtp` clear. The MTP layer, when present, is held in `mtp`,
9//!   at index `num_hidden_layers`, with no hyper-connection.
10//!
11//! It executes nothing: there is no forward pass here, and the MLP site is a named step
12//! (`ResidualStep::Mlp`) with its kind recorded.
13//!
14//! Facts about the reference checkpoint `LibertAIDAI/GLM-5.3-Flash-NVFP4@9e0d74e3`, checked
15//! against its fixtures by `crates/model-engine/tests/glm5next_skeleton.rs`:
16//! * The structural (non-MLP) surface is 1,047 tensors in three signatures: 34 KDA layers
17//!   x 23, 11 DSA layers x 22, the MTP layer x 20, plus 3 non-layer tensors.
18//! * The MTP layer has no hyper-connection: all 270 `hc_*` tensors are on layers 0..=44.
19//! * The final collapse is an unweighted mean: the checkpoint has no `hc_head` tensor.
20//! * Layers 0..=2 carry a dense MLP (`first_k_dense_replace = 3`); the other 42 text layers
21//!   and the MTP layer route to experts.
22
23use std::collections::{BTreeMap, BTreeSet};
24
25use anyhow::{Result, bail};
26use metrale_config::{LayerType, ModelConfig};
27
28mod budget;
29pub use budget::{StateBudget, StructuralAccounting};
30
31/// 2026-09-25: Which mixer a layer runs. Narrower than [`LayerType`]: `from_config` refuses
32/// the kinds GLM-5.3 does not have rather than carrying them as unreachable arms.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Mixer {
35    /// 2026-09-25: Kimi Delta Attention: recurrent state, no KV cache.
36    Kda,
37    /// 2026-09-25: Sparse MLA behind a top-k indexer; attends a paged KV cache.
38    Dsa,
39}
40
41/// 2026-09-25: Which MLP a layer runs. Recorded, not executed, by this module.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Mlp {
44    Dense,
45    RoutedMoe,
46}
47
48/// 2026-09-25: One decoder layer's structure.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct SkeletonLayer {
51    pub index: usize,
52    pub mixer: Mixer,
53    pub mlp: Mlp,
54    /// 2026-09-25: False only for the MTP layer.
55    pub hyper_connection: bool,
56    /// 2026-09-25: True only for the MTP layer, which is not part of the text stack.
57    pub is_mtp: bool,
58}
59
60/// 2026-09-25: One step of a layer's residual path, in execution order.
61///
62/// At each of its two sites a text layer runs `hc_pre` → norm → sublayer → `hc_post`; the
63/// residual streams entering `hc_pre` are the ones `hc_post` mixes through `comb`.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum ResidualStep {
66    /// 2026-09-25: Snapshot the `hc_mult` streams as the residual for this site's `hc_post`.
67    SaveResidual,
68    /// 2026-09-25: `hc_pre`: collapse the streams to one sequence, emit `post`/`comb`.
69    HcPre(Site),
70    /// 2026-09-25: RMSNorm on the collapsed sequence, named by its weight.
71    Norm(&'static str),
72    /// 2026-09-25: The attention mixer.
73    Mixer,
74    /// 2026-09-25: The MLP site.
75    Mlp,
76    /// 2026-09-25: `hc_post`: `out[j] = post[j]*block_out + Σ_i comb[i][j]*residual[i]`.
77    HcPost(Site),
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum Site {
82    Attn,
83    Ffn,
84}
85
86/// 2026-09-25: What the model does after the last text layer.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum FinalStep {
89    /// 2026-09-25: Unweighted mean over the `hc_mult` streams; it has no parameters.
90    HyperHeadMean,
91    Norm(&'static str),
92    LmHead,
93}
94
95/// 2026-09-25: Which per-sequence state a layer needs. Each layer needs exactly one kind.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum StateKind {
98    /// 2026-09-25: Recurrent `[heads, head_dim, head_dim]` fp32 state plus a bf16
99    /// causal-conv window.
100    KdaRecurrent,
101    /// 2026-09-25: Paged KV blocks plus the indexer's own key state.
102    SparseKv,
103}
104
105#[derive(Debug, Clone)]
106pub struct Glm5NextTextSkeleton {
107    pub hidden_size: usize,
108    pub hc_mult: usize,
109    /// 2026-09-25: Text stack, indices `0..num_hidden_layers`. Never includes the MTP layer.
110    pub layers: Vec<SkeletonLayer>,
111    /// 2026-09-25: The MTP layer, index `num_hidden_layers`. Held apart from `layers` so a loop
112    /// over `layers` covers only the text stack.
113    pub mtp: Option<SkeletonLayer>,
114}
115
116const NON_LAYER_TENSORS: [&str; 3] = [
117    "model.language_model.embed_tokens.weight",
118    "model.language_model.norm.weight",
119    "lm_head.weight",
120];
121
122/// 2026-09-25: The `self_attn` tensors of a KDA block.
123const KDA_ATTN: [&str; 15] = [
124    "self_attn.q_proj.weight",
125    "self_attn.k_proj.weight",
126    "self_attn.v_proj.weight",
127    "self_attn.b_proj.weight",
128    "self_attn.f_a_proj.weight",
129    "self_attn.f_b_proj.weight",
130    "self_attn.g_a_proj.weight",
131    "self_attn.g_b_proj.weight",
132    "self_attn.q_conv1d.weight",
133    "self_attn.k_conv1d.weight",
134    "self_attn.v_conv1d.weight",
135    "self_attn.A_log",
136    "self_attn.dt_bias",
137    "self_attn.o_norm.weight",
138    "self_attn.o_proj.weight",
139];
140
141/// 2026-09-25: The `self_attn` tensors of a DSA block, the MTP layer's included.
142///
143/// `indexer.k_norm` is a LayerNorm and carries a bias, the only `.bias` in this structural
144/// contract; a binder that takes only `.weight` drops it silently.
145const DSA_ATTN: [&str; 14] = [
146    "self_attn.q_a_proj.weight",
147    "self_attn.q_a_layernorm.weight",
148    "self_attn.q_b_proj.weight",
149    "self_attn.kv_a_proj_with_mqa.weight",
150    "self_attn.kv_a_layernorm.weight",
151    "self_attn.kv_b_proj.weight",
152    "self_attn.o_proj.weight",
153    "self_attn.indexer.wq_b.weight",
154    "self_attn.indexer.wk.weight",
155    "self_attn.indexer.k_norm.weight",
156    "self_attn.indexer.k_norm.bias",
157    "self_attn.indexer.weights_proj.weight",
158    "self_attn.indexer.index_kpool_compress_ape",
159    "self_attn.indexer.index_kpool_compress_gate",
160];
161
162const LAYER_NORMS: [&str; 2] = ["input_layernorm.weight", "post_attention_layernorm.weight"];
163
164const HC_PARAMS: [&str; 6] = [
165    "hc_attn_fn",
166    "hc_attn_base",
167    "hc_attn_scale",
168    "hc_ffn_fn",
169    "hc_ffn_base",
170    "hc_ffn_scale",
171];
172
173/// 2026-09-25: Tensors the MTP layer carries besides its DSA block and layer norms. Its mixer
174/// is an ordinary DSA block, so MTP needs no attention implementation of its own.
175const MTP_HEAD: [&str; 4] = [
176    "eh_proj.weight",
177    "enorm.weight",
178    "hnorm.weight",
179    "shared_head.norm.weight",
180];
181
182fn qualify(layer: usize, leaf: &str) -> String {
183    format!("model.language_model.layers.{layer}.{leaf}")
184}
185
186impl Glm5NextTextSkeleton {
187    /// 2026-09-25: Derive the topology from a parsed config. Every kind is read, never
188    /// defaulted. Errors on a `model_type` other than `glm5_next`, a `layer_types` length other
189    /// than `num_hidden_layers`, `hc_mult == 0`, a layer type other than linear or sparse
190    /// attention, and more than one MTP layer.
191    pub fn from_config(cfg: &ModelConfig) -> Result<Self> {
192        if cfg.model_type != "glm5_next" {
193            bail!(
194                "Glm5NextTextSkeleton built from a {:?} config",
195                cfg.model_type
196            );
197        }
198        if cfg.layer_types.len() != cfg.num_hidden_layers {
199            bail!(
200                "layer_types has {} entries, num_hidden_layers is {}",
201                cfg.layer_types.len(),
202                cfg.num_hidden_layers
203            );
204        }
205        if cfg.hc_mult == 0 {
206            bail!("glm5_next skeleton needs hc_mult > 0; got 0 (mHC is not optional here)");
207        }
208        let dense: BTreeSet<usize> = cfg.mlp_only_layers.iter().copied().collect();
209
210        let mixer_of = |t: LayerType, i: usize| -> Result<Mixer> {
211            Ok(match t {
212                LayerType::LinearAttention => Mixer::Kda,
213                LayerType::SparseAttention => Mixer::Dsa,
214                other => {
215                    bail!("layer {i}: GLM-5.3-Flash has no {other:?} layers; refusing to bind one")
216                }
217            })
218        };
219
220        let mut layers = Vec::with_capacity(cfg.num_hidden_layers);
221        for (i, t) in cfg.layer_types.iter().enumerate() {
222            layers.push(SkeletonLayer {
223                index: i,
224                mixer: mixer_of(*t, i)?,
225                mlp: if dense.contains(&i) {
226                    Mlp::Dense
227                } else {
228                    Mlp::RoutedMoe
229                },
230                hyper_connection: true,
231                is_mtp: false,
232            });
233        }
234
235        // 2026-09-25: The MTP layer sits past the text stack (`ModelConfig::layer_type_at`
236        // resolves it through `mtp_layer_types`) and is never appended to `layers`.
237        let mtp = match cfg.mtp_layer_types.len() {
238            0 => None,
239            1 => {
240                let i = cfg.num_hidden_layers;
241                Some(SkeletonLayer {
242                    index: i,
243                    mixer: mixer_of(cfg.mtp_layer_types[0], i)?,
244                    mlp: Mlp::RoutedMoe,
245                    // 2026-09-25: The checkpoint has no `hc_*` tensor on the MTP layer.
246                    hyper_connection: false,
247                    is_mtp: true,
248                })
249            }
250            n => bail!("glm5_next skeleton expects 0 or 1 MTP layers, config declares {n}"),
251        };
252
253        Ok(Self {
254            hidden_size: cfg.hidden_size,
255            hc_mult: cfg.hc_mult,
256            layers,
257            mtp,
258        })
259    }
260
261    /// 2026-09-25: Text stack plus the MTP layer, in checkpoint index order.
262    pub fn all_layers(&self) -> Vec<SkeletonLayer> {
263        let mut v = self.layers.clone();
264        v.extend(self.mtp);
265        v
266    }
267
268    /// 2026-09-25: The structural (non-MLP, non-MoE) tensors this layer needs, fully qualified.
269    pub fn structural_tensors(&self, l: &SkeletonLayer) -> Vec<String> {
270        let mut v: Vec<String> = match l.mixer {
271            Mixer::Kda => KDA_ATTN.iter().map(|t| qualify(l.index, t)).collect(),
272            Mixer::Dsa => DSA_ATTN.iter().map(|t| qualify(l.index, t)).collect(),
273        };
274        v.extend(LAYER_NORMS.iter().map(|t| qualify(l.index, t)));
275        if l.hyper_connection {
276            v.extend(HC_PARAMS.iter().map(|t| qualify(l.index, t)));
277        }
278        if l.is_mtp {
279            v.extend(MTP_HEAD.iter().map(|t| qualify(l.index, t)));
280        }
281        v
282    }
283
284    /// 2026-09-25: Every structural tensor the whole skeleton needs, including the non-layer
285    /// ones.
286    pub fn structural_tensor_set(&self) -> BTreeSet<String> {
287        let mut s: BTreeSet<String> = NON_LAYER_TENSORS.iter().map(|t| t.to_string()).collect();
288        for l in self.all_layers() {
289            s.extend(self.structural_tensors(&l));
290        }
291        s
292    }
293
294    /// 2026-09-25: The residual/norm/mHC wiring for one layer, in execution order.
295    ///
296    /// A layer without a hyper-connection (the MTP layer) gets the ordinary
297    /// `x = x + sublayer(norm(x))` shape, with no mHC steps.
298    pub fn residual_plan(&self, l: &SkeletonLayer) -> Vec<ResidualStep> {
299        let mut p = Vec::new();
300        for (site, norm, sub) in [
301            (Site::Attn, LAYER_NORMS[0], ResidualStep::Mixer),
302            (Site::Ffn, LAYER_NORMS[1], ResidualStep::Mlp),
303        ] {
304            if l.hyper_connection {
305                p.push(ResidualStep::SaveResidual);
306                p.push(ResidualStep::HcPre(site));
307                p.push(ResidualStep::Norm(norm));
308                p.push(sub);
309                p.push(ResidualStep::HcPost(site));
310            } else {
311                p.push(ResidualStep::SaveResidual);
312                p.push(ResidualStep::Norm(norm));
313                p.push(sub);
314            }
315        }
316        p
317    }
318
319    /// 2026-09-25: What runs after the last text layer.
320    pub fn final_plan(&self) -> [FinalStep; 3] {
321        [
322            FinalStep::HyperHeadMean,
323            FinalStep::Norm("model.language_model.norm.weight"),
324            FinalStep::LmHead,
325        ]
326    }
327
328    /// 2026-09-25: Per-layer state kind, for every layer including the MTP layer.
329    pub fn state_plan(&self) -> BTreeMap<usize, StateKind> {
330        self.all_layers()
331            .iter()
332            .map(|l| {
333                (
334                    l.index,
335                    match l.mixer {
336                        Mixer::Kda => StateKind::KdaRecurrent,
337                        Mixer::Dsa => StateKind::SparseKv,
338                    },
339                )
340            })
341            .collect()
342    }
343
344    /// 2026-09-25: Layers whose recurrent state is carried between steps.
345    pub fn kda_state_layers(&self) -> Vec<usize> {
346        self.state_plan()
347            .into_iter()
348            .filter(|(_, k)| *k == StateKind::KdaRecurrent)
349            .map(|(i, _)| i)
350            .collect()
351    }
352
353    /// 2026-09-25: Layers that consume paged KV blocks, the MTP layer included.
354    pub fn kv_cache_layers(&self) -> Vec<usize> {
355        self.state_plan()
356            .into_iter()
357            .filter(|(_, k)| *k == StateKind::SparseKv)
358            .map(|(i, _)| i)
359            .collect()
360    }
361
362    /// 2026-09-25: The per-sequence state budget, from `cfg`'s geometry. `kda_recurrent` is
363    /// sized at 4 bytes (fp32) per element.
364    pub fn state_budget(&self, cfg: &ModelConfig, num_spec: usize) -> StateBudget {
365        let kda_layers = self.kda_state_layers().len();
366        let kv_layers = self.layers.iter().filter(|l| l.mixer == Mixer::Dsa).count();
367        let heads = cfg.linear_num_value_heads.max(1);
368        let hd = cfg.linear_value_head_dim.max(1);
369        let conv_dim = cfg.linear_num_key_heads * cfg.linear_key_head_dim * 2
370            + cfg.linear_num_value_heads * cfg.linear_value_head_dim;
371        StateBudget {
372            kda_recurrent: kda_layers * heads * hd * hd * 4,
373            kda_conv: kda_layers * conv_dim * (cfg.linear_conv_kernel_dim - 1 + num_spec) * 2,
374            dsa_kv_per_token: kv_layers * cfg.kv_lora_rank * 2,
375            // 2026-09-25: Two buffers of `index_head_dim` BF16 (`k_normed` and the compress
376            // `gate`) plus the 1 B validity flag, as `Glm5NextDsaState::alloc` allocates.
377            dsa_indexer_per_token: kv_layers * (cfg.index_head_dim * 2 * 2 + 1),
378            mhc_highway_per_token: self.hc_mult * self.hidden_size * 4,
379            moe_routing_per_token: cfg.num_experts * 4 + cfg.num_experts_per_tok * 8,
380        }
381    }
382
383    /// 2026-09-25: Account the skeleton's structural contract against a checkpoint's tensor
384    /// names.
385    ///
386    /// `available` is the full name list. A required name that is absent goes to `missing`.
387    /// Any other name goes to `deferred` when it contains `.mlp.` or starts with
388    /// `model.visual.`/`model.vision`, and to `unexpected` otherwise.
389    pub fn account(&self, available: &BTreeSet<String>) -> StructuralAccounting {
390        let required = self.structural_tensor_set();
391        let mut missing = Vec::new();
392        for r in &required {
393            if !available.contains(r) {
394                missing.push(r.clone());
395            }
396        }
397        let mut unexpected = Vec::new();
398        let mut deferred = 0usize;
399        for a in available {
400            if required.contains(a) {
401                continue;
402            }
403            let is_mlp = a.contains(".mlp.");
404            let is_vision = a.starts_with("model.visual.") || a.starts_with("model.vision");
405            if is_mlp || is_vision {
406                deferred += 1;
407            } else {
408                unexpected.push(a.clone());
409            }
410        }
411        StructuralAccounting {
412            required: required.len(),
413            bound: required.len() - missing.len(),
414            missing,
415            unexpected,
416            deferred,
417        }
418    }
419}