atlas_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 ATLAS_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 // ββ YaRN RoPE scaling (Mistral Small 4) ββ
541 /// YaRN scaling factor (`yarn.factor`). 0.0 = YaRN disabled, use plain RoPE.
542 #[serde(default)]
543 pub yarn_factor: f32,
544 /// YaRN low-rotation cutoff (`yarn.alpha` in Mistral params,
545 /// `beta_slow` in HF transformers terminology).
546 #[serde(default)]
547 pub yarn_beta_slow: f32,
548 /// YaRN high-rotation cutoff (`yarn.beta` in Mistral params,
549 /// `beta_fast` in HF transformers terminology).
550 #[serde(default)]
551 pub yarn_beta_fast: f32,
552 /// YaRN original context length used for the correction range
553 /// (`yarn.original_max_position_embeddings`).
554 #[serde(default)]
555 pub yarn_original_max_position_embeddings: usize,
556 /// Multiplier applied to both YaRN cosine and sine values. 1.0 means no
557 /// attention-temperature scaling.
558 #[serde(default = "default_one_f32")]
559 pub yarn_attention_factor: f32,
560 /// llama_4_scaling Q temperature beta (`llama_4_scaling.beta`).
561 /// Q is multiplied by `1 + beta * log(1 + floor(pos / original_max_pos))`
562 /// after RoPE. 0.0 = disabled. Mistral Small 4 uses 0.1.
563 #[serde(default)]
564 pub llama_4_scaling_beta: f32,
565 /// llama_4_scaling original context length for the Q temperature scale.
566 #[serde(default)]
567 pub llama_4_scaling_original_max_position_embeddings: usize,
568
569 // ββ Vision (Qwen3-VL only) ββ
570 /// Vision encoder configuration parsed from `vision_config` in config.json.
571 /// None for text-only models.
572 #[serde(skip)]
573 pub vision: Option<VisionConfig>,
574
575 /// Advertised quantization format + algorithm + per-module ignore list.
576 /// Populated from `config.json::quantization_config` or a sibling
577 /// `hf_quant_config.json` at `parse_config` time. `None` for
578 /// un-quantized BF16/FP16 checkpoints. Consumed by the `QuantFormat`
579 /// dispatcher (`crates/spark-model/src/quant_format/`) to pick the
580 /// correct on-disk loader without guessing from tensor names.
581 #[serde(skip)]
582 pub quantization_config: Option<QuantizationConfig>,
583
584 // ββ Architecture flags (set by parse_config, not from JSON) ββ
585 /// Whether Q projection includes an output gate (Q+Gate interleaved, 2x q_dim).
586 /// False for Qwen3-VL, Nemotron-H, Mistral (ungated Q).
587 #[serde(skip)]
588 pub attn_gated: bool,
589 /// The GDN gated-norm's gate activation is SIGMOID rather than SiLU.
590 ///
591 /// The reference constructs its `RMSNormGated` with
592 /// `activation = output_gate_type or hidden_act`, so on a checkpoint
593 /// with `output_gate_type: "sigmoid"` (Qwen3.8-Flash-Next) BOTH the
594 /// attention output gate and the GDN norm gate are sigmoid. Every other
595 /// Qwen-family GDN model gates with SiLU. Found by the qwen4_exp phase-E
596 /// bisect: recurrence proven correct, norm stage off at cos 0.81, and
597 /// sigmoid closed it to 0.0.
598 #[serde(default)]
599 pub gdn_norm_sigmoid: bool,
600 /// Whether config.json wraps the LLM config in a nested field (e.g., `text_config`).
601 /// Determines weight prefix auto-detection behavior.
602 #[serde(skip)]
603 pub nested_config: bool,
604 /// MRoPE (multi-modal rotary position embedding) section sizes in
605 /// `[T, H, W]` order. `[0, 0, 0]` = scalar RoPE (default for Qwen3.5
606 /// and earlier). Qwen3.6 uses `[11, 11, 10]`. Summed Γ 2 == rotary_dim.
607 #[serde(skip)]
608 pub mrope_section: [usize; 3],
609 /// MRoPE channel layout: `true` = round-robin `[T H W T H W β¦]` (Qwen3.6),
610 /// `false` = contiguous `[Tβ¦T | Hβ¦H | Wβ¦W]` (Qwen3-VL non-interleaved).
611 /// Ignored when `mrope_section == [0, 0, 0]`.
612 #[serde(skip)]
613 pub mrope_interleaved: bool,
614
615 // ββ Weight key prefix (set by parser for conditional generation models) ββ
616 #[serde(skip)]
617 pub weight_prefix: String,
618
619 /// `--profile`: skip CUDA graphs, sync and time each layer.
620 ///
621 /// Carried here rather than through `ATLAS_PROFILE`, which `serve.rs` used
622 /// to `set_var` at runtime under a `// SAFETY: called before any threads
623 /// are spawned` comment that was **already false** β the tokio pool, the
624 /// startup blocking thread, the signal listener, the TUI thread and the
625 /// OOM watchdog all exist by then, and a concurrent `getenv` during
626 /// `setenv` is UB. A field on the config the model already receives has
627 /// none of that hazard.
628 #[serde(skip)]
629 pub profile: bool,
630
631 // ββ Expert Parallelism (set at runtime, not from config.json) ββ
632 #[serde(skip)]
633 pub ep_rank: usize,
634 #[serde(skip)]
635 pub ep_world_size: usize,
636
637 // ββ Tensor Parallelism (set at runtime, not from config.json) ββ
638 /// TP rank within the TP sub-communicator. 0 if `tp_world_size==1`.
639 #[serde(skip)]
640 pub tp_rank: usize,
641 /// Number of TP ranks. 1 = no TP. Composes with EP statically:
642 /// attention/MLP weights are TP-sharded; MoE expert weights are EP-sharded.
643 #[serde(skip)]
644 pub tp_world_size: usize,
645
646 // ββ Served context (set at runtime from `--max-seq-len`) ββ
647 /// The serve's `--max-seq-len`. 0 when nobody set it (a unit test, an offline tool),
648 /// which every reader must treat as "unknown" and fall back from β never as zero
649 /// context. Distinct from `max_position_embeddings`, which is the checkpoint's claim
650 /// (1,048,576 on GLM-5.3) rather than what this process reserved memory for.
651 #[serde(skip)]
652 pub serve_max_seq_len: usize,
653
654 // ββ FP8 KV cache calibration (set at runtime from CLI) ββ
655 /// Number of warmup tokens for online FP8 KV scale calibration.
656 /// 0 = disabled (use static scales from checkpoint or uncalibrated 1.0).
657 #[serde(skip)]
658 pub fp8_kv_calibration_tokens: usize,
659 /// Headroom multiplier on the first-observe absmax when freezing the online
660 /// FP8 KV scale (`--fp8-kv-headroom`, default 2.0). The first observe sees
661 /// only the first prefill chunk, so the frozen scale covers headroomΓ its
662 /// observed max β later tokens that grow don't clip, at <1 bit of precision.
663 #[serde(skip)]
664 pub fp8_kv_headroom: f32,
665
666 // ββ Gemma-4 specific ββ
667 /// Final logit softcapping: logits = cap * tanh(logits / cap).
668 /// 0.0 = disabled (default for all models except Gemma-4 which uses 30.0).
669 #[serde(skip)]
670 pub final_logit_softcapping: f32,
671 /// Embedding scale factor: embeddings *= scale after lookup.
672 /// 0.0 = disabled (default). Gemma models use sqrt(hidden_size).
673 #[serde(skip)]
674 pub embed_scale: f32,
675
676 // ββ MiniMax M2 specific ββ
677 /// MoE routing activation. "" = default softmax. "sigmoid" = DeepSeek-V3
678 /// / MiniMax-M2 style: raw gate logits pass through sigmoid to produce
679 /// per-expert scores in (0,1), independent (not normalized across
680 /// experts). Top-k selection may use a bias term (see `moe_routing_bias`).
681 #[serde(default)]
682 pub scoring_func: String,
683 /// If true, a per-expert `e_score_correction_bias` tensor is added to
684 /// routing scores *for top-k selection only* (not dispatch weighting).
685 /// This is the DeepSeek-V3 loss-free balancing trick. The bias tensor
686 /// itself lives in the checkpoint (typically one `[num_experts]` vector
687 /// per MoE layer).
688 #[serde(default)]
689 pub use_routing_bias: bool,
690 /// QK normalization granularity. "" = none (Qwen3-Next default).
691 /// "per_layer" = each attention layer has its own learned q_layernorm /
692 /// k_layernorm weight of shape `[head_dim]`, applied after Q/K projection
693 /// and before RoPE (MiniMax M2).
694 #[serde(default)]
695 pub qk_norm_type: String,
696 /// Number of sequential MTP draft modules. 0 = no MTP. 1 = existing
697 /// Atlas MTP path (Qwen3.5). 3 = MiniMax M2 (each module is a single
698 /// transformer layer that predicts one future token).
699 #[serde(default)]
700 pub num_mtp_modules: usize,
701 /// Transformer layers per MTP module. 1 for MiniMax M2 (3 modules Γ 1
702 /// layer = 3 future-token predictors).
703 #[serde(default)]
704 pub mtp_transformer_layers: usize,
705 /// Explicit rotary dimension from config (bypasses partial_rotary_factor
706 /// computation). MiniMax M2 ships `rotary_dim: 64` while head_dim=128,
707 /// so the rotary factor is 0.5 β we honor the explicit int value when
708 /// present for byte-exact rope dim.
709 #[serde(default)]
710 pub rotary_dim: usize,
711
712 /// Target-model layer indices to capture intermediate hidden states from
713 /// for DFlash speculative decoding. Sourced from the drafter's
714 /// `dflash_config.target_layer_ids` (e.g., `[1, 10, 19, 28, 37]` for
715 /// Qwen3.6-35B-A3B-DFlash). Empty when DFlash is disabled β its presence
716 /// gates `TransformerModel::dflash_hidden_save` allocation and the
717 /// per-layer capture hooks. Order matters: shallow-to-deep concatenation
718 /// is what the drafter's `fc` projection expects.
719 #[serde(default)]
720 pub dflash_capture_layers: Vec<usize>,
721 /// Resolved DFlash drafter Ξ³ (block size), set by the factory alongside
722 /// `dflash_capture_layers`. Sizes the SSM verify intermediate pools at
723 /// the ACTUAL K = Ξ³+1 instead of the legacy 17-wide ceiling β at Ξ³=8,
724 /// C=8 that ceiling alone cost ~12 GB of pool (2026-08-19 256K/C8 boot
725 /// ledger). `None` = DFlash inactive (or unknown β 17-wide fallback).
726 pub dflash_gamma: Option<usize>,
727
728 /// LoRA adapter rank ceiling (`--max-lora-rank`). `0` = LoRA disabled.
729 /// Set programmatically before model build (never parsed from the HF
730 /// `config.json`); the only consumer is `BufferSizes`, which sizes the
731 /// adapter delta scratch from it. `adapter_*` naming avoids the MLA
732 /// `*lora_rank` collision (`config.rs:182-207`).
733 #[serde(default)]
734 pub adapter_max_rank: usize,
735
736 // ββ Kimi K3 (KDA + gated MLA + AttnRes + Stable LatentMoE) ββ
737 /// AttnRes residual-mix block size (`attn_res_block_size`). 0 = no AttnRes.
738 #[serde(default)]
739 pub attn_res_block_size: usize,
740 /// KDA full-rank output gate (`linear_attn_config.use_full_rank_gate`).
741 #[serde(default)]
742 pub use_full_rank_gate: bool,
743 /// MLA NoPE (`mla_use_nope`).
744 #[serde(default)]
745 pub mla_use_nope: bool,
746 /// MLA output gate (`mla_use_output_gate`).
747 #[serde(default)]
748 pub mla_use_output_gate: bool,
749 /// LatentMoE RMS after the routed down-project (`latent_moe_use_norm`).
750 #[serde(default)]
751 pub latent_moe_use_norm: bool,
752 /// HF `hidden_act` (K3 production: `situ`). Empty = family default.
753 #[serde(default)]
754 pub hidden_act: String,
755 /// SiTU-GLU Ξ² (`activation_situ_beta`). 0.0 = unused.
756 #[serde(default)]
757 pub activation_situ_beta: f32,
758 /// SiTU-GLU linear Ξ² (`activation_situ_linear_beta`). 0.0 = unused.
759 #[serde(default)]
760 pub activation_situ_linear_beta: f32,
761 /// Shared-expert count (`num_shared_experts` / `n_shared_experts`).
762 #[serde(default)]
763 pub n_shared_experts: usize,
764}
765
766/// Advertised weight-quantization layout, as declared in the HF
767/// `config.json`'s `quantization_config` block (or a sibling
768/// `hf_quant_config.json`). This is the authoritative signal for
769/// format dispatch β the `QuantFormat` trait prefers this over
770/// tensor-name sniffing, matching the dispatch model used by vLLM /
771/// TensorRT-LLM / SGLang.
772///
773/// `quant_method` is the serialization scheme:
774/// * `"compressed-tensors"` β Neural Magic / llm-compressor. Uses
775/// `weight_packed` + `weight_global_scale` + `input_global_scale`.
776/// Commonly paired with `format = "nvfp4-pack-quantized"` or
777/// `"float-quantized"`.
778/// * `"modelopt"` β NVIDIA TensorRT ModelOpt. Uses `weight` (as the
779/// packed FP4 payload when `quant_algo == "NVFP4"`) + `weight_scale`
780/// + `weight_scale_2` + `input_scale`.
781/// * `"fp8"` β native FP8 block-scaled (e.g. `Qwen/Qwen3.5-35B-A3B-FP8`)
782/// with `weight_scale_inv` sibling tensors.
783///
784/// `ignore_modules` holds the already-expanded list of module-path
785/// patterns that should be loaded as dense BF16 rather than quantized.
786/// Patterns use HF glob semantics (`*` matches any non-`.` sub-path).
787#[derive(Debug, Clone)]
788pub struct QuantizationConfig {
789 /// Raw `quant_method` string from the config. Stable values:
790 /// `"compressed-tensors"`, `"modelopt"`, `"fp8"`.
791 pub quant_method: String,
792 /// ModelOpt-specific algorithm label: `"NVFP4"`, `"FP8"`, β¦
793 /// Empty string for schemes that don't declare one (e.g. plain FP8).
794 pub quant_algo: String,
795 /// Optional `format` string (compressed-tensors uses this for
796 /// `"nvfp4-pack-quantized"` and friends).
797 pub format: String,
798 /// Module-path globs that should stay BF16 (the "ignore list" in
799 /// ModelOpt terminology; `targets`/`exclude_modules` in compressed-
800 /// tensors). Example entries: `"lm_head"`,
801 /// `"model.layers.*.self_attn*"`.
802 pub ignore_modules: Vec<String>,
803}
804
805/// Vision encoder configuration for Qwen3-VL models.
806#[derive(Debug, Clone)]
807pub struct VisionConfig {
808 /// Number of ViT transformer blocks (depth=27).
809 pub depth: usize,
810 /// ViT hidden dimension (1152).
811 pub hidden_size: usize,
812 /// Number of attention heads (16).
813 pub num_heads: usize,
814 /// Spatial patch size in pixels (16).
815 pub patch_size: usize,
816 /// Temporal patch size: still images are replicated this many times (2).
817 pub temporal_patch_size: usize,
818 /// 2Γ2 spatial merge: this many patch-lengths merged into one token (2).
819 pub spatial_merge_size: usize,
820 /// ViT MLP intermediate size (4304).
821 pub intermediate_size: usize,
822 /// Projection output dimension = LLM hidden_size (2048).
823 pub out_hidden_size: usize,
824 /// Layer indices after which deepstack mergers are applied ([8, 16, 24]).
825 pub deepstack_visual_indexes: Vec<usize>,
826 /// Placeholder token ID that marks where vision embeddings get spliced
827 /// into the text embedding stream. Qwen3-VL uses 151655; Qwen3.6 uses
828 /// 248056. When 0 the runtime falls back to the legacy Qwen3-VL value.
829 pub image_pad_token_id: u32,
830 /// Placeholder token ID for VIDEO frames, the temporal sibling of
831 /// [`Self::image_pad_token_id`]. Qwen3.6/3.8 use 248057. A distinct token
832 /// is what lets the position builder tell a video item from an image one
833 /// in the token stream, which matters because their MRoPE treatment
834 /// differs: an image holds T constant across its whole pad run, a video
835 /// advances T once per temporal group. When 0 the runtime falls back to
836 /// the family default.
837 pub video_pad_token_id: u32,
838 /// Resolved vision AREA bound in pixels: the operator's
839 /// `--vision-max-pixels`, else the checkpoint's `preprocessor_config.json`,
840 /// else `None`.
841 ///
842 /// β
THE SINGLE SOURCE OF TRUTH, and it exists because there used to be
843 /// two. The CPU preprocessor clamped every image to 1280px on the long
844 /// side while the GPU encoder allocated its buffers for 6400 patches β
845 /// exactly 1280Γ1280 β with nothing in the code connecting them. They
846 /// agreed only by coincidence, so raising one on 2026-08-14 made every
847 /// image above 1280px fail an H2D copy with `CUDA_ERROR_INVALID_VALUE`
848 /// from deep inside the scheduler.
849 ///
850 /// Both now derive from this field, resolved once at config load, before
851 /// the encoder is constructed. `None` keeps the historical behaviour on
852 /// both sides.
853 pub max_pixels: Option<usize>,
854}
855
856impl VisionConfig {
857 /// Dimension of the merger input (spatial_merge_sizeΒ² Γ hidden_size).
858 pub fn merger_input_size(&self) -> usize {
859 self.spatial_merge_size * self.spatial_merge_size * self.hidden_size
860 }
861}
862
863pub(crate) fn default_one() -> usize {
864 1
865}
866pub(crate) fn default_one_f64() -> f64 {
867 1.0
868}
869pub(crate) fn default_one_f32() -> f32 {
870 1.0
871}
872pub(crate) fn default_rope_theta() -> f64 {
873 10000.0
874}
875pub(crate) fn default_rms_eps() -> f64 {
876 1e-6
877}
878pub(crate) fn default_partial_rotary() -> f64 {
879 1.0
880}
881pub(crate) fn default_conv_kernel() -> usize {
882 4
883}
884
885mod dispatch;
886mod factory;
887mod gguf;
888#[cfg(test)]
889mod kv_completeness_tests;
890mod methods;
891mod parsers;
892#[cfg(test)]
893mod tests;
894
895pub use dispatch::parse_config;
896pub use gguf::{GgufConfigInputs, GgufMeta, config_from_gguf};
897pub use parsers::{
898 PEFT_SUPPORTED_TARGET_MODULES, PeftAdapterConfig, allow_partial_targets,
899 glm5_next_mtp_layer_index, parse_mistral_params, parse_peft_adapter_config,
900 parse_quantization_config,
901};
902pub(crate) use parsers::{
903 parse_deepseek_v4, parse_gemma4_params, parse_glm5_next, parse_kimi_k3, parse_laguna,
904 parse_longcat_ngram, parse_minimax_m2, parse_qwen4_exp, parse_step3p7, parse_vision_config,
905 sanitize_kimi_k3_eos,
906};
907
908pub(crate) fn finalize_config(config: &mut ModelConfig, raw: &serde_json::Value) -> Result<()> {
909 if config.quantization_config.is_none() {
910 config.quantization_config = parse_quantization_config(raw);
911 }
912 validate_config(config)
913}
914
915/// Post-parse validation for ModelConfig.
916/// Checks layer_types length matches num_hidden_layers and SSM field consistency.
917pub(crate) fn validate_config(config: &ModelConfig) -> Result<()> {
918 if !config.layer_types.is_empty() && config.layer_types.len() != config.num_hidden_layers {
919 anyhow::bail!(
920 "layer_types length ({}) doesn't match num_hidden_layers ({}) in config.json",
921 config.layer_types.len(),
922 config.num_hidden_layers,
923 );
924 }
925
926 if !config.num_attention_heads_per_layer.is_empty()
927 && config.num_attention_heads_per_layer.len() != config.num_hidden_layers
928 {
929 anyhow::bail!(
930 "num_attention_heads_per_layer length ({}) doesn't match num_hidden_layers ({}) in config.json",
931 config.num_attention_heads_per_layer.len(),
932 config.num_hidden_layers,
933 );
934 }
935
936 let has_ssm =
937 config.layer_types.contains(&LayerType::LinearAttention) || config.linear_num_key_heads > 0;
938 if has_ssm && config.linear_num_key_heads == 0 && config.mamba_num_heads == 0 {
939 anyhow::bail!(
940 "SSM model detected but linear_num_key_heads is 0 in config.json. \
941 This field is required for SSM/GDN layer initialization."
942 );
943 }
944
945 if config.mamba_num_heads > 0 {
946 if config.mamba_head_dim == 0 {
947 anyhow::bail!("mamba_head_dim must be greater than zero");
948 }
949 if config.ssm_state_size == 0 {
950 anyhow::bail!("ssm_state_size must be greater than zero");
951 }
952 if config.n_groups == 0 {
953 anyhow::bail!("n_groups must be greater than zero");
954 }
955 if !config.mamba2_d_inner().is_multiple_of(config.n_groups) {
956 anyhow::bail!("mamba_num_heads * mamba_head_dim must be divisible by n_groups");
957 }
958 }
959
960 Ok(())
961}