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 #[serde(default)]
345 pub linear_gate_lower_bound: f32,
346 /// SwiGLU clamp bound (`swiglu_limit`). 0.0 = the model does not clamp.
347 ///
348 /// π΄ GLM-5.3-Flash declares `swiglu_limit = 10.0`, and the clamp is **asymmetric**:
349 /// `gate` is upper-bounded only, `up` is bounded both ways. Read, never defaulted for a
350 /// model that declares it β a missing clamp is invisible on well-scaled activations and
351 /// silently wrong on the tails (see `kernels/gb10/common/glm5next_ffn.cu`).
352 #[serde(default)]
353 pub swiglu_limit: f32,
354 /// Decoder-layer indices that use a dense MLP instead of routed experts.
355 #[serde(default)]
356 pub mlp_only_layers: Vec<usize>,
357 /// LatentMoE: latent projection dimension for routed experts (Super 120B).
358 /// When present, routed experts operate in latent space `[moe_latent_size]`
359 /// instead of full `[hidden_size]`. Absent for Nano 30B.
360 #[serde(default)]
361 pub moe_latent_size: usize,
362 /// Per-layer MoE intermediate sizes (Nemotron-H Puzzle heterogeneous channel
363 /// pruning). Length == `num_hidden_layers`; 0 for non-MoE layers. Empty =
364 /// fall back to scalar `moe_intermediate_size` for every MoE layer.
365 #[serde(default, skip_deserializing, skip_serializing)]
366 pub moe_intermediate_sizes: Vec<usize>,
367 /// Per-layer top-K expert counts (Puzzle). Same layout as
368 /// `moe_intermediate_sizes`. Empty = use scalar `num_experts_per_tok`.
369 #[serde(default, skip_deserializing, skip_serializing)]
370 pub num_experts_per_toks: Vec<usize>,
371
372 // ββ MLA (Multi-head Latent Attention) β Mistral Small 4 / DeepSeek-V2+ ββ
373 /// KV latent dimension for compressed cache. 0 = standard attention (no MLA).
374 #[serde(default)]
375 pub kv_lora_rank: usize,
376 /// Per-layer KV cache dimensions (num_kv_heads, head_dim). Populated by
377 /// loaders for heterogeneous-attention models (e.g. Gemma-4 with sliding
378 /// and full attention having different head counts and dims). Empty for
379 /// homogeneous models.
380 #[serde(default, skip_deserializing, skip_serializing)]
381 pub kv_layer_dims: Vec<(usize, usize)>,
382 /// Query latent dimension for low-rank Q projection. 0 = standard Q.
383 #[serde(default)]
384 pub q_lora_rank: usize,
385 /// Non-rotary portion of Q/K per head (NoPE component).
386 #[serde(default)]
387 pub qk_nope_head_dim: usize,
388 /// Rotary portion of Q/K per head (RoPE component).
389 #[serde(default)]
390 pub qk_rope_head_dim: usize,
391 /// Value dimension per head (may differ from head_dim in MLA).
392 #[serde(default)]
393 pub v_head_dim: usize,
394
395 // ββ N-gram embeddings β LongCat-Flash-Lite / Qwen3.8-Flash-Next ββ
396 // (arxiv 2601.21204: capacity via hashed n-gram lookup tables instead of
397 // more experts.) `emb_split_num * (emb_neighbor_num - 1)` embedding
398 // tables, each ~`ngram_vocab_size_ratio * vocab_size` rows at
399 // `hidden_size / num_tables` dims; ids are a polynomial rolling hash of
400 // the current + previous n-1 TOKEN IDS (never hidden states), each
401 // looked-up vector is projected to hidden and ADDED to the base token
402 // embedding, and the sum is scaled by 1/(1 + num_tables). Reference:
403 // bench/ngram_ref/{modeling_longcat_ngram.py, ngram_parity.py}.
404 /// N-gram table size multiplier: each table has ~ratio*vocab_size rows
405 /// (LongCat-Lite: 78 β ~10.2M rows/table). 0 = no n-gram embeddings.
406 #[serde(default)]
407 pub ngram_vocab_size_ratio: usize,
408 /// Largest n-gram size N (LongCat-Lite: 4 β bigram/trigram/4-gram).
409 #[serde(default)]
410 pub emb_neighbor_num: usize,
411 /// Independent hash splits K per n-gram size (LongCat-Lite: 4).
412 #[serde(default)]
413 pub emb_split_num: usize,
414 /// Rows per n-gram HEAD, absolute (`ngram_vocab_size_base`).
415 ///
416 /// The Qwen4-Exp form of the same idea LongCat expresses as a ratio:
417 /// LongCat says "ratio x vocab_size rows per table", Qwen says
418 /// "20,000,000 rows per head" outright. Mutually exclusive with
419 /// `ngram_vocab_size_ratio` β whichever the checkpoint declares wins,
420 /// and the authoritative per-head sizes/offsets ship as I64 tensors
421 /// (`ngram_heads_vocab_sizes` / `ngram_heads_offsets`) which the loader
422 /// reads rather than re-deriving. 0 = not a base-form checkpoint.
423 #[serde(default)]
424 pub ngram_vocab_size_base: usize,
425 /// Physical shard count of the n-gram table (`split_ngram_parts`).
426 ///
427 /// PURELY a file-layout fact, NOT an architectural one: Qwen4-Exp stores
428 /// one logical `[sum(head_vocabs), ngram_dim]` table as 128 equal
429 /// `shard_N.weight` tensors. The head ranges are independent of the
430 /// shard boundaries and a head can straddle several shards, so the row
431 /// cache must address the logical table and translate. 0 = unsharded.
432 #[serde(default)]
433 pub ngram_split_parts: usize,
434 /// Decoder layers that carry a PLE (per-layer-embedding) n-gram
435 /// injection (`ple_layer_ids`). Qwen4-Exp injects at ONE layer, not at
436 /// the token embedding the way LongCat does β which is why this is a
437 /// layer list and not a flag. Empty = no PLE.
438 #[serde(default)]
439 pub ple_layer_ids: Vec<usize>,
440 /// Depthwise conv width inside the PLE block (`ple_conv_kernel_size`).
441 /// 0 = no conv.
442 #[serde(default)]
443 pub ple_conv_kernel_size: usize,
444
445 // ββ DeepSeek-V4 low-rank / grouped output projection + mHC ββ
446 /// Output projection latent dimension for low-rank O projection.
447 /// DeepSeek-V4 uses `o_lora_rank` to compress the output projection.
448 /// 0 = standard O (no low-rank compression).
449 #[serde(default)]
450 pub o_lora_rank: usize,
451 /// Number of block-diagonal groups for the grouped O projection (wo_a).
452 /// DeepSeek-V4-Flash splits the n_heads*head_dim attention output into
453 /// `o_groups` independent groups, each projected to `o_lora_rank` before the
454 /// follow-up wo_b mixes the `o_groups*o_lora_rank` vector back to hidden_size.
455 /// 0 = ungrouped (dense O).
456 #[serde(default)]
457 pub o_groups: usize,
458 /// YaRN attention-temperature `mscale` (`rope_scaling.mscale`). HF default
459 /// is 1.0 when absent. DeepSeek folds `_mscale` into the rope cos/sin.
460 #[serde(default)]
461 pub yarn_mscale: f32,
462 /// YaRN attention-temperature `mscale_all_dim` (`rope_scaling.mscale_all_dim`).
463 /// HF default is 0.0 when absent. Used in the `_mscale` ratio that scales
464 /// the rope cos/sin (and, when non-zero, the softmax scale).
465 #[serde(default)]
466 pub yarn_mscale_all_dim: f32,
467 /// Number of hyper-connection residual streams per block (`hc_mult`).
468 /// 0 = disabled (every model except DeepSeek-V4). DeepSeek-V4 uses 4.
469 #[serde(default)]
470 pub hc_mult: usize,
471 /// Number of Sinkhorn normalization iterations for the HC mixing matrix
472 /// (`hc_sinkhorn_iters`). DeepSeek-V4 default is 20.
473 #[serde(default)]
474 pub hc_sinkhorn_iters: usize,
475 /// Numerical-stability epsilon for HC sigmoid/softmax/Sinkhorn (`hc_eps`).
476 /// DeepSeek-V4 default is 1e-6.
477 #[serde(default)]
478 pub hc_eps: f32,
479 /// Rank of the hyper-connection input mixer (`hc_lowrank`).
480 ///
481 /// Qwen4-Exp mixes the `hc_mult` residual streams through a LOW-RANK
482 /// pair β `input_mix_weight_down [r, hc_mult*hidden]` then
483 /// `input_mix_weight_up [hc_mult*hidden, r]` β where DeepSeek-V4 uses a
484 /// Sinkhorn-normalized square matrix. The two share `hc_mult` and the
485 /// stream-major layout but NOT the mixing math, so a non-zero value here
486 /// selects the low-rank variant. 0 = DeepSeek-V4's Sinkhorn form.
487 #[serde(default)]
488 pub hc_lowrank: usize,
489 /// The checkpoint carries NO final normalization before `lm_head`: the
490 /// real one is applied inside the hyper-connection mixer while the
491 /// residual streams collapse. Applying the engine's ones-placeholder RMS
492 /// anyway still DIVIDES the hidden by its per-token RMS, which flattens
493 /// the logits by a per-token factor (measured 1.16-1.63x vs the reference
494 /// forward on qwen4_exp) -- an uninvited temperature multiplier that
495 /// argmax survives but sampling does not. When set, the final-norm step
496 /// becomes an identity copy.
497 #[serde(default)]
498 pub final_norm_identity: bool,
499 /// Per-layer compression ratios for hybrid attention (CSA/HCA).
500 /// 0 = full attention, >0 = compressed attention with that ratio.
501 /// Length equals num_hidden_layers. Empty = all layers full attention.
502 #[serde(default)]
503 pub compress_ratios: Vec<usize>,
504 /// Number of semantic-indexer heads used by DeepSeek-V4 CSA layers.
505 #[serde(default)]
506 pub index_n_heads: usize,
507 /// Per-head dimension of the DeepSeek-V4 semantic indexer.
508 #[serde(default)]
509 pub index_head_dim: usize,
510 /// Maximum compressed-history rows selected per query by the semantic indexer.
511 #[serde(default)]
512 pub index_topk: usize,
513 /// Indexer compression ratio, recorded WITHOUT populating
514 /// `compress_ratios`.
515 ///
516 /// Qwen3.8-Flash-Next's QSA indexer is inert below its budget β selection
517 /// is `topk(min(budget/ratio, complete_blocks))`, so at
518 /// `seq_len <= index_topk` every block is chosen and dense attention is
519 /// exact. Keeping `compress_ratios` empty stops DeepSeek-V4's compressor
520 /// being dispatched in its place; keeping the ratio here lets a loader
521 /// refuse above the budget instead of silently attending densely.
522 /// 0 = no indexer.
523 #[serde(default)]
524 pub index_compress_ratio: usize,
525 /// GLM-5.3 DSA: tokens per k-pool (`index_kpool`). The pool budget is
526 /// `index_topk / index_kpool`, so this is not cosmetic β it sets how many
527 /// candidates the top-k actually ranks. 0 = model has no k-pooling.
528 #[serde(default)]
529 pub index_kpool: usize,
530 /// GLM-5.3 DSA: always append the trailing partial pool's tokens to the
531 /// selection, widening the emitted index row by `index_kpool - 1`.
532 #[serde(default)]
533 pub index_kpool_always_select_tail: bool,
534 /// Number of hash-based attention layers (DeepSeek-V4 HCA). 0 = none.
535 #[serde(default)]
536 pub num_hash_layers: usize,
537
538 // ββ YaRN RoPE scaling (Mistral Small 4) ββ
539 /// YaRN scaling factor (`yarn.factor`). 0.0 = YaRN disabled, use plain RoPE.
540 #[serde(default)]
541 pub yarn_factor: f32,
542 /// YaRN low-rotation cutoff (`yarn.alpha` in Mistral params,
543 /// `beta_slow` in HF transformers terminology).
544 #[serde(default)]
545 pub yarn_beta_slow: f32,
546 /// YaRN high-rotation cutoff (`yarn.beta` in Mistral params,
547 /// `beta_fast` in HF transformers terminology).
548 #[serde(default)]
549 pub yarn_beta_fast: f32,
550 /// YaRN original context length used for the correction range
551 /// (`yarn.original_max_position_embeddings`).
552 #[serde(default)]
553 pub yarn_original_max_position_embeddings: usize,
554 /// Multiplier applied to both YaRN cosine and sine values. 1.0 means no
555 /// attention-temperature scaling.
556 #[serde(default = "default_one_f32")]
557 pub yarn_attention_factor: f32,
558 /// llama_4_scaling Q temperature beta (`llama_4_scaling.beta`).
559 /// Q is multiplied by `1 + beta * log(1 + floor(pos / original_max_pos))`
560 /// after RoPE. 0.0 = disabled. Mistral Small 4 uses 0.1.
561 #[serde(default)]
562 pub llama_4_scaling_beta: f32,
563 /// llama_4_scaling original context length for the Q temperature scale.
564 #[serde(default)]
565 pub llama_4_scaling_original_max_position_embeddings: usize,
566
567 // ββ Vision (Qwen3-VL only) ββ
568 /// Vision encoder configuration parsed from `vision_config` in config.json.
569 /// None for text-only models.
570 #[serde(skip)]
571 pub vision: Option<VisionConfig>,
572
573 /// Advertised quantization format + algorithm + per-module ignore list.
574 /// Populated from `config.json::quantization_config` or a sibling
575 /// `hf_quant_config.json` at `parse_config` time. `None` for
576 /// un-quantized BF16/FP16 checkpoints. Consumed by the `QuantFormat`
577 /// dispatcher (`crates/spark-model/src/quant_format/`) to pick the
578 /// correct on-disk loader without guessing from tensor names.
579 #[serde(skip)]
580 pub quantization_config: Option<QuantizationConfig>,
581
582 // ββ Architecture flags (set by parse_config, not from JSON) ββ
583 /// Whether Q projection includes an output gate (Q+Gate interleaved, 2x q_dim).
584 /// False for Qwen3-VL, Nemotron-H, Mistral (ungated Q).
585 #[serde(skip)]
586 pub attn_gated: bool,
587 /// The GDN gated-norm's gate activation is SIGMOID rather than SiLU.
588 ///
589 /// The reference constructs its `RMSNormGated` with
590 /// `activation = output_gate_type or hidden_act`, so on a checkpoint
591 /// with `output_gate_type: "sigmoid"` (Qwen3.8-Flash-Next) BOTH the
592 /// attention output gate and the GDN norm gate are sigmoid. Every other
593 /// Qwen-family GDN model gates with SiLU. Found by the qwen4_exp phase-E
594 /// bisect: recurrence proven correct, norm stage off at cos 0.81, and
595 /// sigmoid closed it to 0.0.
596 #[serde(default)]
597 pub gdn_norm_sigmoid: bool,
598 /// Whether config.json wraps the LLM config in a nested field (e.g., `text_config`).
599 /// Determines weight prefix auto-detection behavior.
600 #[serde(skip)]
601 pub nested_config: bool,
602 /// MRoPE (multi-modal rotary position embedding) section sizes in
603 /// `[T, H, W]` order. `[0, 0, 0]` = scalar RoPE (default for Qwen3.5
604 /// and earlier). Qwen3.6 uses `[11, 11, 10]`. Summed Γ 2 == rotary_dim.
605 #[serde(skip)]
606 pub mrope_section: [usize; 3],
607 /// MRoPE channel layout: `true` = round-robin `[T H W T H W β¦]` (Qwen3.6),
608 /// `false` = contiguous `[Tβ¦T | Hβ¦H | Wβ¦W]` (Qwen3-VL non-interleaved).
609 /// Ignored when `mrope_section == [0, 0, 0]`.
610 #[serde(skip)]
611 pub mrope_interleaved: bool,
612
613 // ββ Weight key prefix (set by parser for conditional generation models) ββ
614 #[serde(skip)]
615 pub weight_prefix: String,
616
617 /// `--profile`: skip CUDA graphs, sync and time each layer.
618 ///
619 /// Carried here rather than through `ATLAS_PROFILE`, which `serve.rs` used
620 /// to `set_var` at runtime under a `// SAFETY: called before any threads
621 /// are spawned` comment that was **already false** β the tokio pool, the
622 /// startup blocking thread, the signal listener, the TUI thread and the
623 /// OOM watchdog all exist by then, and a concurrent `getenv` during
624 /// `setenv` is UB. A field on the config the model already receives has
625 /// none of that hazard.
626 #[serde(skip)]
627 pub profile: bool,
628
629 // ββ Expert Parallelism (set at runtime, not from config.json) ββ
630 #[serde(skip)]
631 pub ep_rank: usize,
632 #[serde(skip)]
633 pub ep_world_size: usize,
634
635 // ββ Tensor Parallelism (set at runtime, not from config.json) ββ
636 /// TP rank within the TP sub-communicator. 0 if `tp_world_size==1`.
637 #[serde(skip)]
638 pub tp_rank: usize,
639 /// Number of TP ranks. 1 = no TP. Composes with EP statically:
640 /// attention/MLP weights are TP-sharded; MoE expert weights are EP-sharded.
641 #[serde(skip)]
642 pub tp_world_size: usize,
643
644 // ββ Served context (set at runtime from `--max-seq-len`) ββ
645 /// The serve's `--max-seq-len`. 0 when nobody set it (a unit test, an offline tool),
646 /// which every reader must treat as "unknown" and fall back from β never as zero
647 /// context. Distinct from `max_position_embeddings`, which is the checkpoint's claim
648 /// (1,048,576 on GLM-5.3) rather than what this process reserved memory for.
649 #[serde(skip)]
650 pub serve_max_seq_len: usize,
651
652 // ββ FP8 KV cache calibration (set at runtime from CLI) ββ
653 /// Number of warmup tokens for online FP8 KV scale calibration.
654 /// 0 = disabled (use static scales from checkpoint or uncalibrated 1.0).
655 #[serde(skip)]
656 pub fp8_kv_calibration_tokens: usize,
657 /// Headroom multiplier on the first-observe absmax when freezing the online
658 /// FP8 KV scale (`--fp8-kv-headroom`, default 2.0). The first observe sees
659 /// only the first prefill chunk, so the frozen scale covers headroomΓ its
660 /// observed max β later tokens that grow don't clip, at <1 bit of precision.
661 #[serde(skip)]
662 pub fp8_kv_headroom: f32,
663
664 // ββ Gemma-4 specific ββ
665 /// Final logit softcapping: logits = cap * tanh(logits / cap).
666 /// 0.0 = disabled (default for all models except Gemma-4 which uses 30.0).
667 #[serde(skip)]
668 pub final_logit_softcapping: f32,
669 /// Embedding scale factor: embeddings *= scale after lookup.
670 /// 0.0 = disabled (default). Gemma models use sqrt(hidden_size).
671 #[serde(skip)]
672 pub embed_scale: f32,
673
674 // ββ MiniMax M2 specific ββ
675 /// MoE routing activation. "" = default softmax. "sigmoid" = DeepSeek-V3
676 /// / MiniMax-M2 style: raw gate logits pass through sigmoid to produce
677 /// per-expert scores in (0,1), independent (not normalized across
678 /// experts). Top-k selection may use a bias term (see `moe_routing_bias`).
679 #[serde(default)]
680 pub scoring_func: String,
681 /// If true, a per-expert `e_score_correction_bias` tensor is added to
682 /// routing scores *for top-k selection only* (not dispatch weighting).
683 /// This is the DeepSeek-V3 loss-free balancing trick. The bias tensor
684 /// itself lives in the checkpoint (typically one `[num_experts]` vector
685 /// per MoE layer).
686 #[serde(default)]
687 pub use_routing_bias: bool,
688 /// QK normalization granularity. "" = none (Qwen3-Next default).
689 /// "per_layer" = each attention layer has its own learned q_layernorm /
690 /// k_layernorm weight of shape `[head_dim]`, applied after Q/K projection
691 /// and before RoPE (MiniMax M2).
692 #[serde(default)]
693 pub qk_norm_type: String,
694 /// Number of sequential MTP draft modules. 0 = no MTP. 1 = existing
695 /// Atlas MTP path (Qwen3.5). 3 = MiniMax M2 (each module is a single
696 /// transformer layer that predicts one future token).
697 #[serde(default)]
698 pub num_mtp_modules: usize,
699 /// Transformer layers per MTP module. 1 for MiniMax M2 (3 modules Γ 1
700 /// layer = 3 future-token predictors).
701 #[serde(default)]
702 pub mtp_transformer_layers: usize,
703 /// Explicit rotary dimension from config (bypasses partial_rotary_factor
704 /// computation). MiniMax M2 ships `rotary_dim: 64` while head_dim=128,
705 /// so the rotary factor is 0.5 β we honor the explicit int value when
706 /// present for byte-exact rope dim.
707 #[serde(default)]
708 pub rotary_dim: usize,
709
710 /// Target-model layer indices to capture intermediate hidden states from
711 /// for DFlash speculative decoding. Sourced from the drafter's
712 /// `dflash_config.target_layer_ids` (e.g., `[1, 10, 19, 28, 37]` for
713 /// Qwen3.6-35B-A3B-DFlash). Empty when DFlash is disabled β its presence
714 /// gates `TransformerModel::dflash_hidden_save` allocation and the
715 /// per-layer capture hooks. Order matters: shallow-to-deep concatenation
716 /// is what the drafter's `fc` projection expects.
717 #[serde(default)]
718 pub dflash_capture_layers: Vec<usize>,
719 /// Resolved DFlash drafter Ξ³ (block size), set by the factory alongside
720 /// `dflash_capture_layers`. Sizes the SSM verify intermediate pools at
721 /// the ACTUAL K = Ξ³+1 instead of the legacy 17-wide ceiling β at Ξ³=8,
722 /// C=8 that ceiling alone cost ~12 GB of pool (2026-08-19 256K/C8 boot
723 /// ledger). `None` = DFlash inactive (or unknown β 17-wide fallback).
724 pub dflash_gamma: Option<usize>,
725
726 /// LoRA adapter rank ceiling (`--max-lora-rank`). `0` = LoRA disabled.
727 /// Set programmatically before model build (never parsed from the HF
728 /// `config.json`); the only consumer is `BufferSizes`, which sizes the
729 /// adapter delta scratch from it. `adapter_*` naming avoids the MLA
730 /// `*lora_rank` collision (`config.rs:182-207`).
731 #[serde(default)]
732 pub adapter_max_rank: usize,
733}
734
735/// Advertised weight-quantization layout, as declared in the HF
736/// `config.json`'s `quantization_config` block (or a sibling
737/// `hf_quant_config.json`). This is the authoritative signal for
738/// format dispatch β the `QuantFormat` trait prefers this over
739/// tensor-name sniffing, matching the dispatch model used by vLLM /
740/// TensorRT-LLM / SGLang.
741///
742/// `quant_method` is the serialization scheme:
743/// * `"compressed-tensors"` β Neural Magic / llm-compressor. Uses
744/// `weight_packed` + `weight_global_scale` + `input_global_scale`.
745/// Commonly paired with `format = "nvfp4-pack-quantized"` or
746/// `"float-quantized"`.
747/// * `"modelopt"` β NVIDIA TensorRT ModelOpt. Uses `weight` (as the
748/// packed FP4 payload when `quant_algo == "NVFP4"`) + `weight_scale`
749/// + `weight_scale_2` + `input_scale`.
750/// * `"fp8"` β native FP8 block-scaled (e.g. `Qwen/Qwen3.5-35B-A3B-FP8`)
751/// with `weight_scale_inv` sibling tensors.
752///
753/// `ignore_modules` holds the already-expanded list of module-path
754/// patterns that should be loaded as dense BF16 rather than quantized.
755/// Patterns use HF glob semantics (`*` matches any non-`.` sub-path).
756#[derive(Debug, Clone)]
757pub struct QuantizationConfig {
758 /// Raw `quant_method` string from the config. Stable values:
759 /// `"compressed-tensors"`, `"modelopt"`, `"fp8"`.
760 pub quant_method: String,
761 /// ModelOpt-specific algorithm label: `"NVFP4"`, `"FP8"`, β¦
762 /// Empty string for schemes that don't declare one (e.g. plain FP8).
763 pub quant_algo: String,
764 /// Optional `format` string (compressed-tensors uses this for
765 /// `"nvfp4-pack-quantized"` and friends).
766 pub format: String,
767 /// Module-path globs that should stay BF16 (the "ignore list" in
768 /// ModelOpt terminology; `targets`/`exclude_modules` in compressed-
769 /// tensors). Example entries: `"lm_head"`,
770 /// `"model.layers.*.self_attn*"`.
771 pub ignore_modules: Vec<String>,
772}
773
774/// Vision encoder configuration for Qwen3-VL models.
775#[derive(Debug, Clone)]
776pub struct VisionConfig {
777 /// Number of ViT transformer blocks (depth=27).
778 pub depth: usize,
779 /// ViT hidden dimension (1152).
780 pub hidden_size: usize,
781 /// Number of attention heads (16).
782 pub num_heads: usize,
783 /// Spatial patch size in pixels (16).
784 pub patch_size: usize,
785 /// Temporal patch size: still images are replicated this many times (2).
786 pub temporal_patch_size: usize,
787 /// 2Γ2 spatial merge: this many patch-lengths merged into one token (2).
788 pub spatial_merge_size: usize,
789 /// ViT MLP intermediate size (4304).
790 pub intermediate_size: usize,
791 /// Projection output dimension = LLM hidden_size (2048).
792 pub out_hidden_size: usize,
793 /// Layer indices after which deepstack mergers are applied ([8, 16, 24]).
794 pub deepstack_visual_indexes: Vec<usize>,
795 /// Placeholder token ID that marks where vision embeddings get spliced
796 /// into the text embedding stream. Qwen3-VL uses 151655; Qwen3.6 uses
797 /// 248056. When 0 the runtime falls back to the legacy Qwen3-VL value.
798 pub image_pad_token_id: u32,
799 /// Placeholder token ID for VIDEO frames, the temporal sibling of
800 /// [`Self::image_pad_token_id`]. Qwen3.6/3.8 use 248057. A distinct token
801 /// is what lets the position builder tell a video item from an image one
802 /// in the token stream, which matters because their MRoPE treatment
803 /// differs: an image holds T constant across its whole pad run, a video
804 /// advances T once per temporal group. When 0 the runtime falls back to
805 /// the family default.
806 pub video_pad_token_id: u32,
807 /// Resolved vision AREA bound in pixels: the operator's
808 /// `--vision-max-pixels`, else the checkpoint's `preprocessor_config.json`,
809 /// else `None`.
810 ///
811 /// β
THE SINGLE SOURCE OF TRUTH, and it exists because there used to be
812 /// two. The CPU preprocessor clamped every image to 1280px on the long
813 /// side while the GPU encoder allocated its buffers for 6400 patches β
814 /// exactly 1280Γ1280 β with nothing in the code connecting them. They
815 /// agreed only by coincidence, so raising one on 2026-08-14 made every
816 /// image above 1280px fail an H2D copy with `CUDA_ERROR_INVALID_VALUE`
817 /// from deep inside the scheduler.
818 ///
819 /// Both now derive from this field, resolved once at config load, before
820 /// the encoder is constructed. `None` keeps the historical behaviour on
821 /// both sides.
822 pub max_pixels: Option<usize>,
823}
824
825impl VisionConfig {
826 /// Dimension of the merger input (spatial_merge_sizeΒ² Γ hidden_size).
827 pub fn merger_input_size(&self) -> usize {
828 self.spatial_merge_size * self.spatial_merge_size * self.hidden_size
829 }
830}
831
832pub(crate) fn default_one() -> usize {
833 1
834}
835pub(crate) fn default_one_f64() -> f64 {
836 1.0
837}
838pub(crate) fn default_one_f32() -> f32 {
839 1.0
840}
841pub(crate) fn default_rope_theta() -> f64 {
842 10000.0
843}
844pub(crate) fn default_rms_eps() -> f64 {
845 1e-6
846}
847pub(crate) fn default_partial_rotary() -> f64 {
848 1.0
849}
850pub(crate) fn default_conv_kernel() -> usize {
851 4
852}
853
854mod dispatch;
855mod factory;
856mod gguf;
857#[cfg(test)]
858mod kv_completeness_tests;
859mod methods;
860mod parsers;
861#[cfg(test)]
862mod tests;
863
864pub use dispatch::parse_config;
865pub use gguf::{GgufConfigInputs, GgufMeta, config_from_gguf};
866pub use parsers::{
867 PEFT_SUPPORTED_TARGET_MODULES, PeftAdapterConfig, allow_partial_targets,
868 glm5_next_mtp_layer_index, parse_mistral_params, parse_peft_adapter_config,
869 parse_quantization_config,
870};
871pub(crate) use parsers::{
872 parse_deepseek_v4, parse_gemma4_params, parse_glm5_next, parse_laguna, parse_longcat_ngram,
873 parse_minimax_m2, parse_qwen4_exp, parse_step3p7, parse_vision_config,
874};
875
876pub(crate) fn finalize_config(config: &mut ModelConfig, raw: &serde_json::Value) -> Result<()> {
877 if config.quantization_config.is_none() {
878 config.quantization_config = parse_quantization_config(raw);
879 }
880 validate_config(config)
881}
882
883/// Post-parse validation for ModelConfig.
884/// Checks layer_types length matches num_hidden_layers and SSM field consistency.
885pub(crate) fn validate_config(config: &ModelConfig) -> Result<()> {
886 if !config.layer_types.is_empty() && config.layer_types.len() != config.num_hidden_layers {
887 anyhow::bail!(
888 "layer_types length ({}) doesn't match num_hidden_layers ({}) in config.json",
889 config.layer_types.len(),
890 config.num_hidden_layers,
891 );
892 }
893
894 if !config.num_attention_heads_per_layer.is_empty()
895 && config.num_attention_heads_per_layer.len() != config.num_hidden_layers
896 {
897 anyhow::bail!(
898 "num_attention_heads_per_layer length ({}) doesn't match num_hidden_layers ({}) in config.json",
899 config.num_attention_heads_per_layer.len(),
900 config.num_hidden_layers,
901 );
902 }
903
904 let has_ssm =
905 config.layer_types.contains(&LayerType::LinearAttention) || config.linear_num_key_heads > 0;
906 if has_ssm && config.linear_num_key_heads == 0 && config.mamba_num_heads == 0 {
907 anyhow::bail!(
908 "SSM model detected but linear_num_key_heads is 0 in config.json. \
909 This field is required for SSM/GDN layer initialization."
910 );
911 }
912
913 if config.mamba_num_heads > 0 {
914 if config.mamba_head_dim == 0 {
915 anyhow::bail!("mamba_head_dim must be greater than zero");
916 }
917 if config.ssm_state_size == 0 {
918 anyhow::bail!("ssm_state_size must be greater than zero");
919 }
920 if config.n_groups == 0 {
921 anyhow::bail!("n_groups must be greater than zero");
922 }
923 if !config.mamba2_d_inner().is_multiple_of(config.n_groups) {
924 anyhow::bail!("mamba_num_heads * mamba_head_dim must be divisible by n_groups");
925 }
926 }
927
928 Ok(())
929}