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}