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}