avarok_core/
config.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3use anyhow::Result;
4use serde::Deserialize;
5
6/// Deserialize a u32 that may be JSON null (treat null as 0).
7fn nullable_u32<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<u32, D::Error> {
8    Option::<u32>::deserialize(d).map(|v| v.unwrap_or(0))
9}
10
11/// `eos_token_id`, which HF allows to be `null`, a scalar, **or an array**.
12///
13/// GLM-5.3-Flash declares three stop tokens as an array, and before Slice 9 that made its
14/// `config.json` fail to parse outright ("invalid type: sequence, expected u32") β€” every family
15/// arm deserializes `eos_token_id` as a bare `u32`. This yields **element 0** as the primary;
16/// the COMPLETE set is recovered separately into [`ModelConfig::eos_token_ids`] by
17/// `parse_config`, so nothing is discarded.
18///
19/// Backward compatible by construction: an array was previously a hard error, so no config that
20/// parses today can change meaning. A parser that wants a different primary (`step3p7` takes the
21/// LAST element) still rewrites the field before deserializing, and that choice is preserved.
22fn eos_token_id_field<'de, D: serde::Deserializer<'de>>(
23    d: D,
24) -> std::result::Result<u32, D::Error> {
25    #[derive(Deserialize)]
26    #[serde(untagged)]
27    enum OneOrMany {
28        One(u32),
29        Many(Vec<u32>),
30    }
31    Ok(match Option::<OneOrMany>::deserialize(d)? {
32        None => 0,
33        Some(OneOrMany::One(v)) => v,
34        Some(OneOrMany::Many(v)) => v.first().copied().unwrap_or(0),
35    })
36}
37
38/// Which dtype ladder GLM-5.3's MoE router runs in.
39///
40/// πŸ”΄ **This is a SEMANTIC switch, not a precision preference.** Slice 10 measured the two
41/// ladders selecting a different top-8 expert set on ~89–95 % of tokens (layers 3/23/44,
42/// T=2048), moving 20–26 % of routed weight mass onto experts the other ladder did not pick.
43/// Treating it as a harmless rounding choice is how a "faster router" silently becomes a
44/// different model.
45///
46/// Deliberately its OWN field, not derived from the quantization config or from
47/// `PrecisionSchedule::router_dtype` (which is a weight-STORAGE schedule with no compute
48/// meaning, and no consumers). Inferring a semantic from an unrelated knob is the defect this
49/// avoids.
50///
51/// GLM-scoped on purpose: no other Atlas model has a contested router ladder, and widening this
52/// into a cross-model routing refactor would be scope Atlas has not asked for.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum Glm5NextRouterMode {
56    /// **CANONICAL / REFERENCE.** HF `transformers` 5.16.1 semantics:
57    /// `F.linear(hidden.type(float32), weight.type(float32))`, and sigmoid / correction bias /
58    /// top-k / renormalisation all in fp32. This is the production default and must not change
59    /// without review.
60    #[default]
61    HfFp32,
62    /// **COMPATIBILITY / ORACLE REPRODUCTION.** Reproduces what vLLM currently does for
63    /// `glm5_next_text`: `GateLinear.out_dtype` resolves to `None` (the fp32 special case in
64    /// `_get_moe_router_dtype` fires only for `glm_moe_dsa` or an explicit `moe_router_dtype`),
65    /// so the gate GEMM runs in the model dtype and `grouped_topk` does no upcast.
66    ///
67    /// Exists so Atlas can reproduce the frozen vLLM oracle's routing for A/B work. **Never a
68    /// production default.**
69    VllmBf16,
70}
71
72impl Glm5NextRouterMode {
73    /// Parse the `moe_router_dtype` config field β€” the same name vLLM reads.
74    ///
75    /// Absent β‡’ [`Self::HfFp32`]. That is the opposite of vLLM's fallthrough, and deliberately
76    /// so: absent means "the checkpoint did not say", and the reference implementation's answer
77    /// for that case is fp32.
78    pub fn from_config_str(s: &str) -> Option<Self> {
79        match s {
80            "float32" | "fp32" => Some(Self::HfFp32),
81            "bfloat16" | "bf16" => Some(Self::VllmBf16),
82            _ => None,
83        }
84    }
85
86    /// True when router math must be carried in fp32.
87    pub fn is_fp32(self) -> bool {
88        matches!(self, Self::HfFp32)
89    }
90}
91
92/// Layer type in a hybrid transformer model.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum LayerType {
96    FullAttention,
97    SlidingAttention,
98    LinearAttention,
99    /// Standalone MoE FFN layer (Nemotron-H: no mixer, just expert routing + FFN).
100    Moe,
101    /// Sparse attention over a per-query selected subset of the KV cache
102    /// (`deepseek_sparse_attention`): a full-rank mixer whose visible key set is
103    /// chosen at runtime by an indexer, not fixed by a window.
104    ///
105    /// Distinct from [`Self::FullAttention`] on purpose. Both attend over the whole
106    /// cache in principle, but a sparse layer additionally needs indexer state, an
107    /// indexer weight family, and a per-query top-k selection step β€” so scheduling,
108    /// cache sizing and weight binding all have to be able to tell them apart. GLM-5.3
109    /// was previously flattened onto `FullAttention` at parse time, which round-tripped
110    /// `deepseek_sparse_attention` into a lie.
111    SparseAttention,
112}
113
114impl LayerType {
115    /// Does this layer attend over a KV cache (as opposed to carrying recurrent state
116    /// or being FFN-only)?
117    pub fn is_attention(self) -> bool {
118        matches!(
119            self,
120            Self::FullAttention | Self::SlidingAttention | Self::SparseAttention
121        )
122    }
123
124    /// The string this layer type round-trips to in a HuggingFace `layer_types` array.
125    pub fn hf_name(self) -> &'static str {
126        match self {
127            Self::FullAttention => "full_attention",
128            Self::SlidingAttention => "sliding_attention",
129            Self::LinearAttention => "linear_attention",
130            Self::Moe => "moe",
131            Self::SparseAttention => "deepseek_sparse_attention",
132        }
133    }
134}
135
136/// Model configuration parsed from HuggingFace config.json.
137///
138/// Single source of truth for model dimensions. All kernel launch
139/// parameters and buffer sizes derive from this struct.
140#[derive(Debug, Clone, Deserialize)]
141pub struct ModelConfig {
142    // ── Core dimensions ──
143    pub hidden_size: usize,
144    #[serde(default)]
145    pub num_hidden_layers: usize,
146    #[serde(default)]
147    pub intermediate_size: usize,
148    #[serde(default)]
149    pub vocab_size: usize,
150
151    // ── Full attention ──
152    #[serde(default)]
153    pub num_attention_heads: usize,
154    /// Per-layer Q-head counts for heterogeneous attention models. Empty means
155    /// every layer uses `num_attention_heads`.
156    #[serde(default)]
157    pub num_attention_heads_per_layer: Vec<usize>,
158    /// GQA: number of K/V heads (≀ `num_attention_heads`). MQA when 1.
159    #[serde(default)]
160    pub num_key_value_heads: usize,
161    #[serde(default)]
162    pub head_dim: usize,
163    /// Fraction of `head_dim` that gets RoPE-rotated. 1.0 = full RoPE,
164    /// 0.5 = half-rotated (Phi-style). Default 1.0.
165    #[serde(default = "default_partial_rotary")]
166    pub partial_rotary_factor: f64,
167
168    // ── Linear attention (SSM / GDN) ──
169    // "linear" = the recurrent state-space / gated-delta-net pathway used
170    // by hybrid models (Qwen3.5/3.6, Nemotron-Nano, MiniMax). Per-token
171    // updates run in O(1) state instead of O(seq) attention.
172    #[serde(default)]
173    pub linear_num_key_heads: usize,
174    #[serde(default)]
175    pub linear_key_head_dim: usize,
176    #[serde(default)]
177    pub linear_num_value_heads: usize,
178    #[serde(default)]
179    pub linear_value_head_dim: usize,
180    /// 1D causal-conv kernel size on the SSM input (typically 3 or 4).
181    #[serde(default = "default_conv_kernel")]
182    pub linear_conv_kernel_dim: usize,
183
184    // ── MoE ──
185    #[serde(default)]
186    pub num_experts: usize,
187    /// LongCat-Flash zero-computation "identity" experts: the router scores
188    /// `num_experts + zero_expert_num` logits, and a token routed to an
189    /// expert id `>= num_experts` receives the INPUT itself scaled by the
190    /// routing weight instead of an expert FFN. 0 = no zero-experts.
191    #[serde(default)]
192    pub zero_expert_num: usize,
193    /// Top-K experts activated per token (the "A" in 35B-A3B = 3B
194    /// active params).
195    #[serde(default = "default_one")]
196    pub num_experts_per_tok: usize,
197    #[serde(default)]
198    pub moe_intermediate_size: usize,
199    #[serde(default)]
200    pub shared_expert_intermediate_size: usize,
201    /// Renormalize routing probabilities so the K active experts sum
202    /// to 1 after top-K selection. Qwen3.5+ sets true; older Qwen2 MoE
203    /// variants set false.
204    #[serde(default)]
205    pub norm_topk_prob: bool,
206    /// MoE block stride: layer `i` uses MoE iff `i % decoder_sparse_step
207    /// == 0`. 1 = every layer is MoE. Mistral / DeepSeek-style stagger
208    /// uses 2.
209    #[serde(default = "default_one")]
210    pub decoder_sparse_step: usize,
211
212    // ── Hybrid layer layout ──
213    /// Per-layer kind (FullAttention | LinearAttention | …) parsed from
214    /// HF config. When empty, falls back to `full_attention_interval`.
215    #[serde(default)]
216    pub layer_types: Vec<LayerType>,
217    /// Per-layer kind for the **extra** layers that sit past `num_hidden_layers`:
218    /// multi-token-prediction / NextN blocks. Empty for models that have none.
219    ///
220    /// Kept separate from `layer_types` on purpose. GLM-5.3-Flash's layer 45 is a real
221    /// decoder layer with its own attention block, but `num_hidden_layers` is 45 and
222    /// `config.layer_types` has 45 entries covering 0..=44 β€” so layer 45 has no honest
223    /// slot there. Appending it would make every length check and every "iterate the text
224    /// stack" loop silently include a speculative-decoding layer. Look it up through
225    /// [`ModelConfig::layer_type_at`], which routes indices past the text stack here.
226    #[serde(default)]
227    pub mtp_layer_types: Vec<LayerType>,
228    /// Stride for full-attention layers in hybrid models when
229    /// `layer_types` is empty: every Nth layer is FullAttention, the
230    /// rest LinearAttention. 1 = every layer is full attention.
231    #[serde(default = "default_one")]
232    pub full_attention_interval: usize,
233    /// Gemma-4 hybrid-attention sliding window size (0 = full attention).
234    /// Sliding layers only attend to the last `sliding_window` KV positions;
235    /// full layers (every 6th in Gemma-4) ignore this (effectively 0).
236    /// Parsed from HF config.json `sliding_window` field. Uses `nullable_u32`
237    /// because Nemotron-H (and some other models) set it to `null` in JSON.
238    #[serde(default, deserialize_with = "nullable_u32")]
239    pub sliding_window: u32,
240
241    // ── Position embeddings ──
242    #[serde(default)]
243    pub max_position_embeddings: usize,
244    #[serde(default = "default_rope_theta")]
245    pub rope_theta: f64,
246
247    // ── Normalization ──
248    #[serde(default = "default_rms_eps")]
249    pub rms_norm_eps: f64,
250
251    // ── Tokenizer ──
252    /// BOS token ID (null β†’ 0 for models without explicit BOS).
253    #[serde(default, deserialize_with = "nullable_u32")]
254    pub bos_token_id: u32,
255    /// Which dtype ladder GLM-5.3's MoE router runs in. See [`Glm5NextRouterMode`] β€” this is a
256    /// semantic switch, and `HfFp32` is the production default.
257    #[serde(default)]
258    pub glm5next_router_mode: Glm5NextRouterMode,
259    /// The PRIMARY stop token. See [`ModelConfig::eos_ids`] for the complete set β€” a config may
260    /// declare several, and this holds only the first.
261    #[serde(default, deserialize_with = "eos_token_id_field")]
262    pub eos_token_id: u32,
263    /// The COMPLETE stop-token set. HF configs are allowed to declare `eos_token_id` as an
264    /// array, and several real checkpoints do β€” GLM-5.3-Flash declares three:
265    /// `154820 <|endoftext|>`, `154827 <|user|>`, `154829 <|observation|>`. `eos_token_id`
266    /// above holds only the PRIMARY one (element 0), which is what every scalar consumer and
267    /// every chat template wants; collapsing to it and discarding the rest is what made an
268    /// agent model unable to stop on its own turn terminators.
269    ///
270    /// Populated by `parse_config` for every model family from the raw JSON, scalar or array.
271    /// Empty means "not populated" (a hand-built `ModelConfig`), NOT "no stop tokens" β€” read it
272    /// through [`ModelConfig::eos_ids`], never directly.
273    #[serde(default)]
274    pub eos_token_ids: Vec<u32>,
275    #[serde(default)]
276    pub tie_word_embeddings: bool,
277    /// CLI override (`--lm-head-dtype`) for LM-head quantization, set at serve time
278    /// (not from config.json). `Some(true)` = force BF16 lm_head; `Some(false)` = force
279    /// the model's quantized lm_head; `None` = use the model-config-driven default.
280    /// Consumed by `skip_lm_head_quantization()`. Replaces the AVAROK_LMHEAD_BF16 env var.
281    #[serde(default)]
282    pub lm_head_bf16_override: Option<bool>,
283    /// When `skip_lm_head_quantization()` == false, quantize the LM head to FP8
284    /// (E4M3, per-row scales, decoded via `w8a16_gemv`) instead of NVFP4.
285    /// Set by `--lm-head-dtype fp8`. Additive: leaves the NVFP4/BF16 paths
286    /// byte-identical when false.
287    #[serde(default)]
288    pub lm_head_fp8: bool,
289
290    // ── Model type ──
291    #[serde(default)]
292    pub model_type: String,
293
294    // ── MTP ──
295    #[serde(default)]
296    pub mtp_num_hidden_layers: usize,
297
298    // ── DSpark ──
299    /// Number of query positions generated by one semi-autoregressive draft pass.
300    /// Zero means the checkpoint does not declare checkpoint-native DSpark.
301    #[serde(default)]
302    pub dspark_block_size: usize,
303    /// Token used to initialize the non-anchor positions in a DSpark block.
304    #[serde(default)]
305    pub dspark_noise_token_id: u32,
306    /// Target layers whose hidden states are concatenated for the DSpark input.
307    #[serde(default)]
308    pub dspark_target_layer_ids: Vec<usize>,
309    /// Width of the low-rank Markov token transition head.
310    #[serde(default)]
311    pub dspark_markov_rank: usize,
312
313    // ── Nemotron-H / Mamba-2 ──
314    #[serde(default)]
315    pub hybrid_override_pattern: String,
316    #[serde(default)]
317    pub mamba_num_heads: usize,
318    #[serde(default)]
319    pub mamba_head_dim: usize,
320    #[serde(default)]
321    pub ssm_state_size: usize,
322    #[serde(default)]
323    pub n_groups: usize,
324    #[serde(default)]
325    pub expand: usize,
326    /// Nemotron-H uses `n_routed_experts` (mapped to `num_experts` in parse_config).
327    #[serde(default)]
328    pub n_routed_experts: usize,
329    /// Nemotron-H uses `norm_eps` (mapped to `rms_norm_eps` in parse_config).
330    #[serde(default)]
331    pub norm_eps: f64,
332    /// Nemotron-H conv kernel size (mapped to `linear_conv_kernel_dim` in parse_config).
333    #[serde(default)]
334    pub conv_kernel: usize,
335    /// Nemotron-H shared expert intermediate (mapped to shared_expert_intermediate_size).
336    #[serde(default)]
337    pub moe_shared_expert_intermediate_size: usize,
338    /// Nemotron-H routed scaling factor for expert outputs.
339    #[serde(default = "default_one_f64")]
340    pub routed_scaling_factor: f64,
341    /// KDA forget-gate lower bound (`linear_attn_config.gate_lower_bound`). GLM-5.3 declares
342    /// -5.0; it bounds the log-decay `kda_gate` produces, so a defaulted 0.0 would clamp the
343    /// decay to a completely different range. Read by the `glm5_next` parser, never guessed.
344    /// K3 production JSON also supplies -5.0. The 0.40B twin omits the key: leave 0.0 and
345    /// map to FLA unbounded (`None`) in `kda_from`.
346    #[serde(default)]
347    pub linear_gate_lower_bound: f32,
348    /// SwiGLU clamp bound (`swiglu_limit`). 0.0 = the model does not clamp.
349    ///
350    /// πŸ”΄ GLM-5.3-Flash declares `swiglu_limit = 10.0`, and the clamp is **asymmetric**:
351    /// `gate` is upper-bounded only, `up` is bounded both ways. Read, never defaulted for a
352    /// model that declares it β€” a missing clamp is invisible on well-scaled activations and
353    /// silently wrong on the tails (see `kernels/gb10/common/glm5next_ffn.cu`).
354    #[serde(default)]
355    pub swiglu_limit: f32,
356    /// Decoder-layer indices that use a dense MLP instead of routed experts.
357    #[serde(default)]
358    pub mlp_only_layers: Vec<usize>,
359    /// LatentMoE: latent projection dimension for routed experts (Super 120B).
360    /// When present, routed experts operate in latent space `[moe_latent_size]`
361    /// instead of full `[hidden_size]`. Absent for Nano 30B.
362    #[serde(default)]
363    pub moe_latent_size: usize,
364    /// Per-layer MoE intermediate sizes (Nemotron-H Puzzle heterogeneous channel
365    /// pruning). Length == `num_hidden_layers`; 0 for non-MoE layers. Empty =
366    /// fall back to scalar `moe_intermediate_size` for every MoE layer.
367    #[serde(default, skip_deserializing, skip_serializing)]
368    pub moe_intermediate_sizes: Vec<usize>,
369    /// Per-layer top-K expert counts (Puzzle). Same layout as
370    /// `moe_intermediate_sizes`. Empty = use scalar `num_experts_per_tok`.
371    #[serde(default, skip_deserializing, skip_serializing)]
372    pub num_experts_per_toks: Vec<usize>,
373
374    // ── MLA (Multi-head Latent Attention) β€” Mistral Small 4 / DeepSeek-V2+ ──
375    /// KV latent dimension for compressed cache. 0 = standard attention (no MLA).
376    #[serde(default)]
377    pub kv_lora_rank: usize,
378    /// Per-layer KV cache dimensions (num_kv_heads, head_dim). Populated by
379    /// loaders for heterogeneous-attention models (e.g. Gemma-4 with sliding
380    /// and full attention having different head counts and dims). Empty for
381    /// homogeneous models.
382    #[serde(default, skip_deserializing, skip_serializing)]
383    pub kv_layer_dims: Vec<(usize, usize)>,
384    /// Query latent dimension for low-rank Q projection. 0 = standard Q.
385    #[serde(default)]
386    pub q_lora_rank: usize,
387    /// Non-rotary portion of Q/K per head (NoPE component).
388    #[serde(default)]
389    pub qk_nope_head_dim: usize,
390    /// Rotary portion of Q/K per head (RoPE component).
391    #[serde(default)]
392    pub qk_rope_head_dim: usize,
393    /// Value dimension per head (may differ from head_dim in MLA).
394    #[serde(default)]
395    pub v_head_dim: usize,
396
397    // ── N-gram embeddings β€” LongCat-Flash-Lite / Qwen3.8-Flash-Next ──
398    // (arxiv 2601.21204: capacity via hashed n-gram lookup tables instead of
399    // more experts.) `emb_split_num * (emb_neighbor_num - 1)` embedding
400    // tables, each ~`ngram_vocab_size_ratio * vocab_size` rows at
401    // `hidden_size / num_tables` dims; ids are a polynomial rolling hash of
402    // the current + previous n-1 TOKEN IDS (never hidden states), each
403    // looked-up vector is projected to hidden and ADDED to the base token
404    // embedding, and the sum is scaled by 1/(1 + num_tables). Reference:
405    // bench/ngram_ref/{modeling_longcat_ngram.py, ngram_parity.py}.
406    /// N-gram table size multiplier: each table has ~ratio*vocab_size rows
407    /// (LongCat-Lite: 78 β†’ ~10.2M rows/table). 0 = no n-gram embeddings.
408    #[serde(default)]
409    pub ngram_vocab_size_ratio: usize,
410    /// Largest n-gram size N (LongCat-Lite: 4 β†’ bigram/trigram/4-gram).
411    #[serde(default)]
412    pub emb_neighbor_num: usize,
413    /// Independent hash splits K per n-gram size (LongCat-Lite: 4).
414    #[serde(default)]
415    pub emb_split_num: usize,
416    /// Rows per n-gram HEAD, absolute (`ngram_vocab_size_base`).
417    ///
418    /// The Qwen4-Exp form of the same idea LongCat expresses as a ratio:
419    /// LongCat says "ratio x vocab_size rows per table", Qwen says
420    /// "20,000,000 rows per head" outright. Mutually exclusive with
421    /// `ngram_vocab_size_ratio` β€” whichever the checkpoint declares wins,
422    /// and the authoritative per-head sizes/offsets ship as I64 tensors
423    /// (`ngram_heads_vocab_sizes` / `ngram_heads_offsets`) which the loader
424    /// reads rather than re-deriving. 0 = not a base-form checkpoint.
425    #[serde(default)]
426    pub ngram_vocab_size_base: usize,
427    /// Physical shard count of the n-gram table (`split_ngram_parts`).
428    ///
429    /// PURELY a file-layout fact, NOT an architectural one: Qwen4-Exp stores
430    /// one logical `[sum(head_vocabs), ngram_dim]` table as 128 equal
431    /// `shard_N.weight` tensors. The head ranges are independent of the
432    /// shard boundaries and a head can straddle several shards, so the row
433    /// cache must address the logical table and translate. 0 = unsharded.
434    #[serde(default)]
435    pub ngram_split_parts: usize,
436    /// Decoder layers that carry a PLE (per-layer-embedding) n-gram
437    /// injection (`ple_layer_ids`). Qwen4-Exp injects at ONE layer, not at
438    /// the token embedding the way LongCat does β€” which is why this is a
439    /// layer list and not a flag. Empty = no PLE.
440    #[serde(default)]
441    pub ple_layer_ids: Vec<usize>,
442    /// Depthwise conv width inside the PLE block (`ple_conv_kernel_size`).
443    /// 0 = no conv.
444    #[serde(default)]
445    pub ple_conv_kernel_size: usize,
446
447    // ── DeepSeek-V4 low-rank / grouped output projection + mHC ──
448    /// Output projection latent dimension for low-rank O projection.
449    /// DeepSeek-V4 uses `o_lora_rank` to compress the output projection.
450    /// 0 = standard O (no low-rank compression).
451    #[serde(default)]
452    pub o_lora_rank: usize,
453    /// Number of block-diagonal groups for the grouped O projection (wo_a).
454    /// DeepSeek-V4-Flash splits the n_heads*head_dim attention output into
455    /// `o_groups` independent groups, each projected to `o_lora_rank` before the
456    /// follow-up wo_b mixes the `o_groups*o_lora_rank` vector back to hidden_size.
457    /// 0 = ungrouped (dense O).
458    #[serde(default)]
459    pub o_groups: usize,
460    /// YaRN attention-temperature `mscale` (`rope_scaling.mscale`). HF default
461    /// is 1.0 when absent. DeepSeek folds `_mscale` into the rope cos/sin.
462    #[serde(default)]
463    pub yarn_mscale: f32,
464    /// YaRN attention-temperature `mscale_all_dim` (`rope_scaling.mscale_all_dim`).
465    /// HF default is 0.0 when absent. Used in the `_mscale` ratio that scales
466    /// the rope cos/sin (and, when non-zero, the softmax scale).
467    #[serde(default)]
468    pub yarn_mscale_all_dim: f32,
469    /// Number of hyper-connection residual streams per block (`hc_mult`).
470    /// 0 = disabled (every model except DeepSeek-V4). DeepSeek-V4 uses 4.
471    #[serde(default)]
472    pub hc_mult: usize,
473    /// Number of Sinkhorn normalization iterations for the HC mixing matrix
474    /// (`hc_sinkhorn_iters`). DeepSeek-V4 default is 20.
475    #[serde(default)]
476    pub hc_sinkhorn_iters: usize,
477    /// Numerical-stability epsilon for HC sigmoid/softmax/Sinkhorn (`hc_eps`).
478    /// DeepSeek-V4 default is 1e-6.
479    #[serde(default)]
480    pub hc_eps: f32,
481    /// Rank of the hyper-connection input mixer (`hc_lowrank`).
482    ///
483    /// Qwen4-Exp mixes the `hc_mult` residual streams through a LOW-RANK
484    /// pair β€” `input_mix_weight_down [r, hc_mult*hidden]` then
485    /// `input_mix_weight_up [hc_mult*hidden, r]` β€” where DeepSeek-V4 uses a
486    /// Sinkhorn-normalized square matrix. The two share `hc_mult` and the
487    /// stream-major layout but NOT the mixing math, so a non-zero value here
488    /// selects the low-rank variant. 0 = DeepSeek-V4's Sinkhorn form.
489    #[serde(default)]
490    pub hc_lowrank: usize,
491    /// The checkpoint carries NO final normalization before `lm_head`: the
492    /// real one is applied inside the hyper-connection mixer while the
493    /// residual streams collapse. Applying the engine's ones-placeholder RMS
494    /// anyway still DIVIDES the hidden by its per-token RMS, which flattens
495    /// the logits by a per-token factor (measured 1.16-1.63x vs the reference
496    /// forward on qwen4_exp) -- an uninvited temperature multiplier that
497    /// argmax survives but sampling does not. When set, the final-norm step
498    /// becomes an identity copy.
499    #[serde(default)]
500    pub final_norm_identity: bool,
501    /// Per-layer compression ratios for hybrid attention (CSA/HCA).
502    /// 0 = full attention, >0 = compressed attention with that ratio.
503    /// Length equals num_hidden_layers. Empty = all layers full attention.
504    #[serde(default)]
505    pub compress_ratios: Vec<usize>,
506    /// Number of semantic-indexer heads used by DeepSeek-V4 CSA layers.
507    #[serde(default)]
508    pub index_n_heads: usize,
509    /// Per-head dimension of the DeepSeek-V4 semantic indexer.
510    #[serde(default)]
511    pub index_head_dim: usize,
512    /// Maximum compressed-history rows selected per query by the semantic indexer.
513    #[serde(default)]
514    pub index_topk: usize,
515    /// Indexer compression ratio, recorded WITHOUT populating
516    /// `compress_ratios`.
517    ///
518    /// Qwen3.8-Flash-Next's QSA indexer is inert below its budget β€” selection
519    /// is `topk(min(budget/ratio, complete_blocks))`, so at
520    /// `seq_len <= index_topk` every block is chosen and dense attention is
521    /// exact. Keeping `compress_ratios` empty stops DeepSeek-V4's compressor
522    /// being dispatched in its place; keeping the ratio here lets a loader
523    /// refuse above the budget instead of silently attending densely.
524    /// 0 = no indexer.
525    #[serde(default)]
526    pub index_compress_ratio: usize,
527    /// GLM-5.3 DSA: tokens per k-pool (`index_kpool`). The pool budget is
528    /// `index_topk / index_kpool`, so this is not cosmetic β€” it sets how many
529    /// candidates the top-k actually ranks. 0 = model has no k-pooling.
530    #[serde(default)]
531    pub index_kpool: usize,
532    /// GLM-5.3 DSA: always append the trailing partial pool's tokens to the
533    /// selection, widening the emitted index row by `index_kpool - 1`.
534    #[serde(default)]
535    pub index_kpool_always_select_tail: bool,
536    /// Number of hash-based attention layers (DeepSeek-V4 HCA). 0 = none.
537    #[serde(default)]
538    pub num_hash_layers: usize,
539
540    // ── DeepSeek-V4.1: shared compressed attention ──
541    /// RoPE base used by the compressor, distinct from `rope_theta`
542    /// (`deepseek41.attention.compress_rope_freq_base`). 0.0 = not set.
543    #[serde(default)]
544    pub compress_rope_theta: f32,
545    /// Layers that PRODUCE the compressed KV consumed by every other layer.
546    /// V4.1 ships `[2, 8, 14, 20]`: four producers for forty blocks, which is
547    /// why its KV cache is ~2.25 KiB/token at FP8 rather than per-layer.
548    /// Empty = every layer produces its own KV (V4 behaviour).
549    #[serde(default)]
550    pub kv_source_layer_ids: Vec<usize>,
551    /// Layers that PRODUCE indexer keys, shared the same way
552    /// (`[2, 8, 14, 20, 24, 28, 32, 36]` on V4.1). Empty = per-layer.
553    #[serde(default)]
554    pub index_source_layer_ids: Vec<usize>,
555    /// Layer whose output drives the candidate prefilter. `None` = no
556    /// prefilter. V4.1 uses layer 20.
557    #[serde(default)]
558    pub candidate_source_layer_id: Option<usize>,
559    /// Candidate prefilter: blocks kept by the top-k. 0 = no prefilter.
560    #[serde(default)]
561    pub candidate_topk_blocks: usize,
562    /// Candidate prefilter: tokens per block. 0 = no prefilter.
563    #[serde(default)]
564    pub candidate_block_size: usize,
565
566    // ── DeepSeek-V4.1: engram ──
567    /// Blocks carrying an engram hash table. V4.1 ships `[1, 14]`.
568    /// Empty = no engram (V4 has none at all).
569    #[serde(default)]
570    pub engram_layer_ids: Vec<usize>,
571    /// Rows in each engram table, one entry per `engram_layer_ids` entry.
572    /// V4.1: `[384006168, 384016682]` β€” 768M rows total, ~60 GiB at Q2_K.
573    /// These tables CANNOT be resident and are the reason the model needs a
574    /// row cache rather than a load.
575    #[serde(default)]
576    pub engram_num_embeddings: Vec<u64>,
577    /// Longest n-gram hashed into the table (V4.1: 4).
578    #[serde(default)]
579    pub engram_max_ngram_size: usize,
580    /// Logical vocab the hash is taken over, BEFORE compression (V4.1: 16000000).
581    #[serde(default)]
582    pub engram_vocab_size: usize,
583    /// Engram attention heads (V4.1: 8).
584    #[serde(default)]
585    pub engram_n_heads: usize,
586    /// Per-head engram dimension (V4.1: 256). A resident row is
587    /// `engram_head_dim` BF16 values = 512 B, against 84 B on disk at Q2_K.
588    #[serde(default)]
589    pub engram_head_dim: usize,
590    /// Pad id used by the engram hash (V4.1: 2). Distinct from the tokenizer's.
591    #[serde(default)]
592    pub engram_pad_token_id: u32,
593    /// Compressed vocab the token_map projects into (V4.1: 99092).
594    #[serde(default)]
595    pub engram_compressed_vocab_size: usize,
596    /// Engram hash multipliers (V4.1: 8 values).
597    #[serde(default)]
598    pub engram_multipliers: Vec<u64>,
599    /// Engram hash primes (V4.1: 48 values).
600    #[serde(default)]
601    pub engram_primes: Vec<u64>,
602    /// Engram per-slot table offsets (V4.1: 48 values).
603    #[serde(default)]
604    pub engram_offsets: Vec<u64>,
605
606    // ── YaRN RoPE scaling (Mistral Small 4) ──
607    /// YaRN scaling factor (`yarn.factor`). 0.0 = YaRN disabled, use plain RoPE.
608    #[serde(default)]
609    pub yarn_factor: f32,
610    /// YaRN low-rotation cutoff (`yarn.alpha` in Mistral params,
611    /// `beta_slow` in HF transformers terminology).
612    #[serde(default)]
613    pub yarn_beta_slow: f32,
614    /// YaRN high-rotation cutoff (`yarn.beta` in Mistral params,
615    /// `beta_fast` in HF transformers terminology).
616    #[serde(default)]
617    pub yarn_beta_fast: f32,
618    /// YaRN original context length used for the correction range
619    /// (`yarn.original_max_position_embeddings`).
620    #[serde(default)]
621    pub yarn_original_max_position_embeddings: usize,
622    /// Multiplier applied to both YaRN cosine and sine values. 1.0 means no
623    /// attention-temperature scaling.
624    #[serde(default = "default_one_f32")]
625    pub yarn_attention_factor: f32,
626    /// llama_4_scaling Q temperature beta (`llama_4_scaling.beta`).
627    /// Q is multiplied by `1 + beta * log(1 + floor(pos / original_max_pos))`
628    /// after RoPE. 0.0 = disabled. Mistral Small 4 uses 0.1.
629    #[serde(default)]
630    pub llama_4_scaling_beta: f32,
631    /// llama_4_scaling original context length for the Q temperature scale.
632    #[serde(default)]
633    pub llama_4_scaling_original_max_position_embeddings: usize,
634
635    // ── Vision (Qwen3-VL only) ──
636    /// Vision encoder configuration parsed from `vision_config` in config.json.
637    /// None for text-only models.
638    #[serde(skip)]
639    pub vision: Option<VisionConfig>,
640
641    /// Advertised quantization format + algorithm + per-module ignore list.
642    /// Populated from `config.json::quantization_config` or a sibling
643    /// `hf_quant_config.json` at `parse_config` time. `None` for
644    /// un-quantized BF16/FP16 checkpoints. Consumed by the `QuantFormat`
645    /// dispatcher (`crates/spark-model/src/quant_format/`) to pick the
646    /// correct on-disk loader without guessing from tensor names.
647    #[serde(skip)]
648    pub quantization_config: Option<QuantizationConfig>,
649
650    // ── Architecture flags (set by parse_config, not from JSON) ──
651    /// Whether Q projection includes an output gate (Q+Gate interleaved, 2x q_dim).
652    /// False for Qwen3-VL, Nemotron-H, Mistral (ungated Q).
653    #[serde(skip)]
654    pub attn_gated: bool,
655    /// The GDN gated-norm's gate activation is SIGMOID rather than SiLU.
656    ///
657    /// The reference constructs its `RMSNormGated` with
658    /// `activation = output_gate_type or hidden_act`, so on a checkpoint
659    /// with `output_gate_type: "sigmoid"` (Qwen3.8-Flash-Next) BOTH the
660    /// attention output gate and the GDN norm gate are sigmoid. Every other
661    /// Qwen-family GDN model gates with SiLU. Found by the qwen4_exp phase-E
662    /// bisect: recurrence proven correct, norm stage off at cos 0.81, and
663    /// sigmoid closed it to 0.0.
664    #[serde(default)]
665    pub gdn_norm_sigmoid: bool,
666    /// Whether config.json wraps the LLM config in a nested field (e.g., `text_config`).
667    /// Determines weight prefix auto-detection behavior.
668    #[serde(skip)]
669    pub nested_config: bool,
670    /// MRoPE (multi-modal rotary position embedding) section sizes in
671    /// `[T, H, W]` order. `[0, 0, 0]` = scalar RoPE (default for Qwen3.5
672    /// and earlier). Qwen3.6 uses `[11, 11, 10]`. Summed Γ— 2 == rotary_dim.
673    #[serde(skip)]
674    pub mrope_section: [usize; 3],
675    /// MRoPE channel layout: `true` = round-robin `[T H W T H W …]` (Qwen3.6),
676    /// `false` = contiguous `[T…T | H…H | W…W]` (Qwen3-VL non-interleaved).
677    /// Ignored when `mrope_section == [0, 0, 0]`.
678    #[serde(skip)]
679    pub mrope_interleaved: bool,
680
681    // ── Weight key prefix (set by parser for conditional generation models) ──
682    #[serde(skip)]
683    pub weight_prefix: String,
684
685    /// `--profile`: skip CUDA graphs, sync and time each layer.
686    ///
687    /// Carried here rather than through `AVAROK_PROFILE`, which `serve.rs` used
688    /// to `set_var` at runtime under a `// SAFETY: called before any threads
689    /// are spawned` comment that was **already false** β€” the tokio pool, the
690    /// startup blocking thread, the signal listener, the TUI thread and the
691    /// OOM watchdog all exist by then, and a concurrent `getenv` during
692    /// `setenv` is UB. A field on the config the model already receives has
693    /// none of that hazard.
694    #[serde(skip)]
695    pub profile: bool,
696
697    // ── Expert Parallelism (set at runtime, not from config.json) ──
698    #[serde(skip)]
699    pub ep_rank: usize,
700    #[serde(skip)]
701    pub ep_world_size: usize,
702
703    // ── Tensor Parallelism (set at runtime, not from config.json) ──
704    /// TP rank within the TP sub-communicator. 0 if `tp_world_size==1`.
705    #[serde(skip)]
706    pub tp_rank: usize,
707    /// Number of TP ranks. 1 = no TP. Composes with EP statically:
708    /// attention/MLP weights are TP-sharded; MoE expert weights are EP-sharded.
709    #[serde(skip)]
710    pub tp_world_size: usize,
711
712    // ── Served context (set at runtime from `--max-seq-len`) ──
713    /// The serve's `--max-seq-len`. 0 when nobody set it (a unit test, an offline tool),
714    /// which every reader must treat as "unknown" and fall back from β€” never as zero
715    /// context. Distinct from `max_position_embeddings`, which is the checkpoint's claim
716    /// (1,048,576 on GLM-5.3) rather than what this process reserved memory for.
717    #[serde(skip)]
718    pub serve_max_seq_len: usize,
719
720    // ── FP8 KV cache calibration (set at runtime from CLI) ──
721    /// Number of warmup tokens for online FP8 KV scale calibration.
722    /// 0 = disabled (use static scales from checkpoint or uncalibrated 1.0).
723    #[serde(skip)]
724    pub fp8_kv_calibration_tokens: usize,
725    /// Headroom multiplier on the first-observe absmax when freezing the online
726    /// FP8 KV scale (`--fp8-kv-headroom`, default 2.0). The first observe sees
727    /// only the first prefill chunk, so the frozen scale covers headroomΓ— its
728    /// observed max β€” later tokens that grow don't clip, at <1 bit of precision.
729    #[serde(skip)]
730    pub fp8_kv_headroom: f32,
731
732    // ── Gemma-4 specific ──
733    /// Final logit softcapping: logits = cap * tanh(logits / cap).
734    /// 0.0 = disabled (default for all models except Gemma-4 which uses 30.0).
735    #[serde(skip)]
736    pub final_logit_softcapping: f32,
737    /// Embedding scale factor: embeddings *= scale after lookup.
738    /// 0.0 = disabled (default). Gemma models use sqrt(hidden_size).
739    #[serde(skip)]
740    pub embed_scale: f32,
741
742    // ── MiniMax M2 specific ──
743    /// MoE routing activation. "" = default softmax. "sigmoid" = DeepSeek-V3
744    /// / MiniMax-M2 style: raw gate logits pass through sigmoid to produce
745    /// per-expert scores in (0,1), independent (not normalized across
746    /// experts). Top-k selection may use a bias term (see `moe_routing_bias`).
747    #[serde(default)]
748    pub scoring_func: String,
749    /// If true, a per-expert `e_score_correction_bias` tensor is added to
750    /// routing scores *for top-k selection only* (not dispatch weighting).
751    /// This is the DeepSeek-V3 loss-free balancing trick. The bias tensor
752    /// itself lives in the checkpoint (typically one `[num_experts]` vector
753    /// per MoE layer).
754    #[serde(default)]
755    pub use_routing_bias: bool,
756    /// QK normalization granularity. "" = none (Qwen3-Next default).
757    /// "per_layer" = each attention layer has its own learned q_layernorm /
758    /// k_layernorm weight of shape `[head_dim]`, applied after Q/K projection
759    /// and before RoPE (MiniMax M2).
760    #[serde(default)]
761    pub qk_norm_type: String,
762    /// Number of sequential MTP draft modules. 0 = no MTP. 1 = existing
763    /// Atlas MTP path (Qwen3.5). 3 = MiniMax M2 (each module is a single
764    /// transformer layer that predicts one future token).
765    #[serde(default)]
766    pub num_mtp_modules: usize,
767    /// Transformer layers per MTP module. 1 for MiniMax M2 (3 modules Γ— 1
768    /// layer = 3 future-token predictors).
769    #[serde(default)]
770    pub mtp_transformer_layers: usize,
771    /// Explicit rotary dimension from config (bypasses partial_rotary_factor
772    /// computation). MiniMax M2 ships `rotary_dim: 64` while head_dim=128,
773    /// so the rotary factor is 0.5 β€” we honor the explicit int value when
774    /// present for byte-exact rope dim.
775    #[serde(default)]
776    pub rotary_dim: usize,
777
778    /// Target-model layer indices to capture intermediate hidden states from
779    /// for DFlash speculative decoding. Sourced from the drafter's
780    /// `dflash_config.target_layer_ids` (e.g., `[1, 10, 19, 28, 37]` for
781    /// Qwen3.6-35B-A3B-DFlash). Empty when DFlash is disabled β€” its presence
782    /// gates `TransformerModel::dflash_hidden_save` allocation and the
783    /// per-layer capture hooks. Order matters: shallow-to-deep concatenation
784    /// is what the drafter's `fc` projection expects.
785    #[serde(default)]
786    pub dflash_capture_layers: Vec<usize>,
787    /// Resolved DFlash drafter Ξ³ (block size), set by the factory alongside
788    /// `dflash_capture_layers`. Sizes the SSM verify intermediate pools at
789    /// the ACTUAL K = Ξ³+1 instead of the legacy 17-wide ceiling β€” at Ξ³=8,
790    /// C=8 that ceiling alone cost ~12 GB of pool (2026-08-19 256K/C8 boot
791    /// ledger). `None` = DFlash inactive (or unknown β†’ 17-wide fallback).
792    pub dflash_gamma: Option<usize>,
793
794    /// LoRA adapter rank ceiling (`--max-lora-rank`). `0` = LoRA disabled.
795    /// Set programmatically before model build (never parsed from the HF
796    /// `config.json`); the only consumer is `BufferSizes`, which sizes the
797    /// adapter delta scratch from it. `adapter_*` naming avoids the MLA
798    /// `*lora_rank` collision (`config.rs:182-207`).
799    #[serde(default)]
800    pub adapter_max_rank: usize,
801
802    // ── Kimi K3 (KDA + gated MLA + AttnRes + Stable LatentMoE) ──
803    /// AttnRes residual-mix block size (`attn_res_block_size`). 0 = no AttnRes.
804    #[serde(default)]
805    pub attn_res_block_size: usize,
806    /// KDA full-rank output gate (`linear_attn_config.use_full_rank_gate`).
807    #[serde(default)]
808    pub use_full_rank_gate: bool,
809    /// MLA NoPE (`mla_use_nope`).
810    #[serde(default)]
811    pub mla_use_nope: bool,
812    /// MLA output gate (`mla_use_output_gate`).
813    #[serde(default)]
814    pub mla_use_output_gate: bool,
815    /// LatentMoE RMS after the routed down-project (`latent_moe_use_norm`).
816    #[serde(default)]
817    pub latent_moe_use_norm: bool,
818    /// HF `hidden_act` (K3 production: `situ`). Empty = family default.
819    #[serde(default)]
820    pub hidden_act: String,
821    /// SiTU-GLU Ξ² (`activation_situ_beta`). 0.0 = unused.
822    #[serde(default)]
823    pub activation_situ_beta: f32,
824    /// SiTU-GLU linear Ξ² (`activation_situ_linear_beta`). 0.0 = unused.
825    #[serde(default)]
826    pub activation_situ_linear_beta: f32,
827    /// Shared-expert count (`num_shared_experts` / `n_shared_experts`).
828    #[serde(default)]
829    pub n_shared_experts: usize,
830}
831
832/// Advertised weight-quantization layout, as declared in the HF
833/// `config.json`'s `quantization_config` block (or a sibling
834/// `hf_quant_config.json`). This is the authoritative signal for
835/// format dispatch β€” the `QuantFormat` trait prefers this over
836/// tensor-name sniffing, matching the dispatch model used by vLLM /
837/// TensorRT-LLM / SGLang.
838///
839/// `quant_method` is the serialization scheme:
840///   * `"compressed-tensors"` β€” Neural Magic / llm-compressor. Uses
841///     `weight_packed` + `weight_global_scale` + `input_global_scale`.
842///     Commonly paired with `format = "nvfp4-pack-quantized"` or
843///     `"float-quantized"`.
844///   * `"modelopt"` β€” NVIDIA TensorRT ModelOpt. Uses `weight` (as the
845///     packed FP4 payload when `quant_algo == "NVFP4"`) + `weight_scale`
846///     + `weight_scale_2` + `input_scale`.
847///   * `"fp8"` β€” native FP8 block-scaled (e.g. `Qwen/Qwen3.5-35B-A3B-FP8`)
848///     with `weight_scale_inv` sibling tensors.
849///
850/// `ignore_modules` holds the already-expanded list of module-path
851/// patterns that should be loaded as dense BF16 rather than quantized.
852/// Patterns use HF glob semantics (`*` matches any non-`.` sub-path).
853#[derive(Debug, Clone)]
854pub struct QuantizationConfig {
855    /// Raw `quant_method` string from the config. Stable values:
856    /// `"compressed-tensors"`, `"modelopt"`, `"fp8"`.
857    pub quant_method: String,
858    /// ModelOpt-specific algorithm label: `"NVFP4"`, `"FP8"`, …
859    /// Empty string for schemes that don't declare one (e.g. plain FP8).
860    pub quant_algo: String,
861    /// Optional `format` string (compressed-tensors uses this for
862    /// `"nvfp4-pack-quantized"` and friends).
863    pub format: String,
864    /// Module-path globs that should stay BF16 (the "ignore list" in
865    /// ModelOpt terminology; `targets`/`exclude_modules` in compressed-
866    /// tensors). Example entries: `"lm_head"`,
867    /// `"model.layers.*.self_attn*"`.
868    pub ignore_modules: Vec<String>,
869}
870
871/// Is GLM-5.3's vision tower enabled for this process?
872///
873/// **Default OFF.** Binding the tower is a MEMORY decision, not a correctness
874/// one: it is 1.05 GiB per rank, and the measurement that lived in
875/// `Glm5NextWeightLoader::binds_vision_encoder` says K=3 at 32 K needs
876/// 13.58 GiB against 12.07 GiB free β€” the tower is exactly the difference
877/// between `--speculative --num-drafts 2` fitting and not. The certified
878/// text recipe must keep the footprint it was certified with, so images are
879/// something an operator asks for, never something a port turns on for them.
880///
881/// THREE sites read this and they must agree, which is why it is one function:
882/// the config parser (so `config.vision` stays `None` and an image request is
883/// refused at the API edge with the existing "no vision config" error), the
884/// loader's `binds_vision_encoder` (so the checkpoint loader withholds
885/// `model.visual.*` instead of uploading it), and `load_vision_encoder` via
886/// `config.vision`. Off, every one of them behaves exactly as it did before
887/// the tower was ported.
888pub fn glm_vision_enabled() -> bool {
889    glm_vision_enabled_from(std::env::var("AVAROK_GLM_VISION").ok().as_deref())
890}
891
892/// The policy half of [`glm_vision_enabled`], split out so both states are
893/// testable β€” setting an environment variable is `unsafe` in this edition and
894/// a test that did it would race every other test in the binary.
895///
896/// Only an explicit affirmative enables. Anything else β€” unset, empty, `0`,
897/// `off`, a typo β€” leaves the tower off, because the failure mode of guessing
898/// wrong in that direction is an OOM on a certified serve.
899pub fn glm_vision_enabled_from(value: Option<&str>) -> bool {
900    matches!(
901        value.map(|v| v.trim().to_ascii_lowercase()).as_deref(),
902        Some("1" | "true" | "yes" | "on")
903    )
904}
905
906#[cfg(test)]
907mod glm_vision_gate_tests {
908    use super::glm_vision_enabled_from;
909
910    /// Off is the default, and "off" has to cover every way an operator can
911    /// fail to say yes β€” the cost of reading a typo as "on" is a 1.05 GiB/rank
912    /// allocation on a serve that was certified without it.
913    #[test]
914    fn only_an_explicit_affirmative_enables_the_tower() {
915        for off in [
916            None,
917            Some(""),
918            Some("0"),
919            Some("off"),
920            Some("false"),
921            Some("no"),
922            Some("yes please"),
923        ] {
924            assert!(!glm_vision_enabled_from(off), "{off:?} must not enable");
925        }
926        for on in ["1", "true", "TRUE", "Yes", " on "] {
927            assert!(glm_vision_enabled_from(Some(on)), "{on:?} must enable");
928        }
929    }
930}
931
932/// Vision encoder configuration for Qwen3-VL models.
933#[derive(Debug, Clone)]
934pub struct VisionConfig {
935    /// Number of ViT transformer blocks (depth=27).
936    pub depth: usize,
937    /// ViT hidden dimension (1152).
938    pub hidden_size: usize,
939    /// Number of attention heads (16).
940    pub num_heads: usize,
941    /// Spatial patch size in pixels (16).
942    pub patch_size: usize,
943    /// Temporal patch size: still images are replicated this many times (2).
944    pub temporal_patch_size: usize,
945    /// 2Γ—2 spatial merge: this many patch-lengths merged into one token (2).
946    pub spatial_merge_size: usize,
947    /// ViT MLP intermediate size (4304).
948    pub intermediate_size: usize,
949    /// Projection output dimension = LLM hidden_size (2048).
950    pub out_hidden_size: usize,
951    /// Layer indices after which deepstack mergers are applied ([8, 16, 24]).
952    pub deepstack_visual_indexes: Vec<usize>,
953    /// Placeholder token ID that marks where vision embeddings get spliced
954    /// into the text embedding stream. Qwen3-VL uses 151655; Qwen3.6 uses
955    /// 248056. When 0 the runtime falls back to the legacy Qwen3-VL value.
956    pub image_pad_token_id: u32,
957    /// Placeholder token ID for VIDEO frames, the temporal sibling of
958    /// [`Self::image_pad_token_id`]. Qwen3.6/3.8 use 248057. A distinct token
959    /// is what lets the position builder tell a video item from an image one
960    /// in the token stream, which matters because their MRoPE treatment
961    /// differs: an image holds T constant across its whole pad run, a video
962    /// advances T once per temporal group. When 0 the runtime falls back to
963    /// the family default.
964    pub video_pad_token_id: u32,
965    /// Resolved vision AREA bound in pixels: the operator's
966    /// `--vision-max-pixels`, else the checkpoint's `preprocessor_config.json`,
967    /// else `None`.
968    ///
969    /// β˜… THE SINGLE SOURCE OF TRUTH, and it exists because there used to be
970    /// two. The CPU preprocessor clamped every image to 1280px on the long
971    /// side while the GPU encoder allocated its buffers for 6400 patches β€”
972    /// exactly 1280Γ—1280 β€” with nothing in the code connecting them. They
973    /// agreed only by coincidence, so raising one on 2026-08-14 made every
974    /// image above 1280px fail an H2D copy with `CUDA_ERROR_INVALID_VALUE`
975    /// from deep inside the scheduler.
976    ///
977    /// Both now derive from this field, resolved once at config load, before
978    /// the encoder is constructed. `None` keeps the historical behaviour on
979    /// both sides.
980    pub max_pixels: Option<usize>,
981
982    // ── Fields below exist because GLM-5.3's tower needs them. Every one has
983    // a default that reproduces the Qwen3-VL behaviour exactly, so a Qwen
984    // checkpoint that declares none of them is unaffected. ──
985    /// Block-MLP activation. `"silu"` for GLM (a clamped SwiGLU);
986    /// `"gelu_pytorch_tanh"` for the Qwen family's 2-matrix GELU MLP.
987    pub hidden_act: String,
988    /// Epsilon of every RMSNorm in the tower (GLM: 1e-5). Unused by the
989    /// Qwen tower, whose norms are LayerNorms at a compiled 1e-6.
990    pub rms_norm_eps: f32,
991    /// Whether the ViT's `qkv`/`proj`/MLP Linears carry biases (GLM: true).
992    pub attention_bias: bool,
993    /// Hidden width of GLM's merger SwiGLU (10240). 0 when the family has no
994    /// such stage.
995    pub projection_intermediate_size: usize,
996    /// Symmetric clamp applied inside GLM's SwiGLU, on both the block MLP and
997    /// the merger (10.0). 0 disables it.
998    pub swiglu_limit: f32,
999    /// Base of the ViT's own rotary embedding. GLM's `vision_config` carries
1000    /// `rope_parameters = {rope_theta: 10000.0, rope_type: "axial"}`.
1001    pub rope_theta: f32,
1002    /// Per-channel normalization the image processor applies. GLM ships
1003    /// CLIP-style stats; the Qwen family ships SigLIP's 0.5/0.5/0.5, which is
1004    /// the default here so an absent `preprocessor_config.json` is unchanged.
1005    pub image_mean: [f32; 3],
1006    pub image_std: [f32; 3],
1007    /// Dynamic-resolution token budget (`processor_config.json`). GLM states
1008    /// its bound in MERGED tokens (16 / 8000) rather than as a pixel area;
1009    /// `min_pixels/max_pixels = tokens * (patch_size * merge_size)^2`.
1010    pub min_image_tokens: usize,
1011    pub max_image_tokens: usize,
1012    /// Patch tokens are emitted in 2x2-block-major order rather than raster.
1013    ///
1014    /// GLM's image processor and its `get_vision_position_ids` share this
1015    /// nesting, which is the ONLY reason the tower's `view(-1, 2, 2, C)`
1016    /// downsample reshape is free. A raster-ordered buffer through the same
1017    /// tower produces a perfectly plausible, wrong embedding β€” so the order is
1018    /// a declared property of the checkpoint, not an implementation detail of
1019    /// whoever writes the loop.
1020    pub block_major_patches: bool,
1021}
1022
1023impl Default for VisionConfig {
1024    fn default() -> Self {
1025        Self {
1026            depth: 0,
1027            hidden_size: 0,
1028            num_heads: 0,
1029            patch_size: 0,
1030            temporal_patch_size: 0,
1031            spatial_merge_size: 0,
1032            intermediate_size: 0,
1033            out_hidden_size: 0,
1034            deepstack_visual_indexes: Vec::new(),
1035            image_pad_token_id: 0,
1036            video_pad_token_id: 0,
1037            max_pixels: None,
1038            hidden_act: "gelu_pytorch_tanh".to_string(),
1039            rms_norm_eps: 1e-6,
1040            attention_bias: true,
1041            projection_intermediate_size: 0,
1042            swiglu_limit: 0.0,
1043            rope_theta: 10_000.0,
1044            image_mean: [0.5, 0.5, 0.5],
1045            image_std: [0.5, 0.5, 0.5],
1046            min_image_tokens: 0,
1047            max_image_tokens: 0,
1048            block_major_patches: false,
1049        }
1050    }
1051}
1052
1053impl VisionConfig {
1054    /// Dimension of the merger input (spatial_merge_sizeΒ² Γ— hidden_size).
1055    pub fn merger_input_size(&self) -> usize {
1056        self.spatial_merge_size * self.spatial_merge_size * self.hidden_size
1057    }
1058}
1059
1060pub(crate) fn default_one() -> usize {
1061    1
1062}
1063pub(crate) fn default_one_f64() -> f64 {
1064    1.0
1065}
1066pub(crate) fn default_one_f32() -> f32 {
1067    1.0
1068}
1069pub(crate) fn default_rope_theta() -> f64 {
1070    10000.0
1071}
1072pub(crate) fn default_rms_eps() -> f64 {
1073    1e-6
1074}
1075pub(crate) fn default_partial_rotary() -> f64 {
1076    1.0
1077}
1078pub(crate) fn default_conv_kernel() -> usize {
1079    4
1080}
1081
1082mod dispatch;
1083mod factory;
1084mod gguf;
1085mod kv_completeness;
1086#[cfg(test)]
1087mod kv_completeness_tests;
1088mod methods;
1089mod parsers;
1090#[cfg(test)]
1091mod tests;
1092
1093pub use dispatch::parse_config;
1094pub use gguf::{GgufConfigInputs, GgufMeta, config_from_gguf};
1095pub use parsers::{
1096    PEFT_SUPPORTED_TARGET_MODULES, PeftAdapterConfig, allow_partial_targets,
1097    glm5_next_mtp_layer_index, parse_mistral_params, parse_peft_adapter_config,
1098    parse_quantization_config,
1099};
1100pub(crate) use parsers::{
1101    parse_deepseek_v4, parse_gemma4_params, parse_glm5_next, parse_kimi_k3, parse_laguna,
1102    parse_longcat_ngram, parse_minimax_m2, parse_qwen4_exp, parse_step3p7, parse_vision_config,
1103    sanitize_kimi_k3_eos,
1104};
1105
1106pub(crate) fn finalize_config(config: &mut ModelConfig, raw: &serde_json::Value) -> Result<()> {
1107    if config.quantization_config.is_none() {
1108        config.quantization_config = parse_quantization_config(raw);
1109    }
1110    validate_config(config)
1111}
1112
1113/// Post-parse validation for ModelConfig.
1114/// Checks layer_types length matches num_hidden_layers and SSM field consistency.
1115pub(crate) fn validate_config(config: &ModelConfig) -> Result<()> {
1116    if !config.layer_types.is_empty() && config.layer_types.len() != config.num_hidden_layers {
1117        anyhow::bail!(
1118            "layer_types length ({}) doesn't match num_hidden_layers ({}) in config.json",
1119            config.layer_types.len(),
1120            config.num_hidden_layers,
1121        );
1122    }
1123
1124    if !config.num_attention_heads_per_layer.is_empty()
1125        && config.num_attention_heads_per_layer.len() != config.num_hidden_layers
1126    {
1127        anyhow::bail!(
1128            "num_attention_heads_per_layer length ({}) doesn't match num_hidden_layers ({}) in config.json",
1129            config.num_attention_heads_per_layer.len(),
1130            config.num_hidden_layers,
1131        );
1132    }
1133
1134    let has_ssm =
1135        config.layer_types.contains(&LayerType::LinearAttention) || config.linear_num_key_heads > 0;
1136    if has_ssm && config.linear_num_key_heads == 0 && config.mamba_num_heads == 0 {
1137        anyhow::bail!(
1138            "SSM model detected but linear_num_key_heads is 0 in config.json. \
1139             This field is required for SSM/GDN layer initialization."
1140        );
1141    }
1142
1143    if config.mamba_num_heads > 0 {
1144        if config.mamba_head_dim == 0 {
1145            anyhow::bail!("mamba_head_dim must be greater than zero");
1146        }
1147        if config.ssm_state_size == 0 {
1148            anyhow::bail!("ssm_state_size must be greater than zero");
1149        }
1150        if config.n_groups == 0 {
1151            anyhow::bail!("n_groups must be greater than zero");
1152        }
1153        if !config.mamba2_d_inner().is_multiple_of(config.n_groups) {
1154            anyhow::bail!("mamba_num_heads * mamba_head_dim must be divisible by n_groups");
1155        }
1156    }
1157
1158    Ok(())
1159}