Skip to main content

rlx_gemma/
config.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Gemma family configuration — HF `config.json` and GGUF metadata.
17
18use rlx_flow::blocks::{GemmaLayerStyle, gemma_strided_layer_mask, gemma2_layer_mask};
19use rlx_gguf::{GgufFile, MetaValue};
20use rlx_ir::op::MaskKind;
21use serde::Deserialize;
22use std::path::Path;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
25#[serde(rename_all = "lowercase")]
26pub enum GemmaArch {
27    #[default]
28    Gemma,
29    Gemma2,
30    Gemma3,
31    Gemma4,
32}
33
34impl GemmaArch {
35    pub fn sliding_window_stride(self) -> usize {
36        match self {
37            GemmaArch::Gemma3 | GemmaArch::Gemma4 => 6,
38            _ => 0,
39        }
40    }
41
42    fn from_gguf_tag(tag: &str) -> Self {
43        match tag {
44            "gemma2" => GemmaArch::Gemma2,
45            "gemma3" | "gemma3n" => GemmaArch::Gemma3,
46            "gemma4" | "gemma4moe" | "gemma4_unified" | "gemma4_unified_text" => GemmaArch::Gemma4,
47            _ => GemmaArch::Gemma,
48        }
49    }
50}
51
52/// One entry in the Gemma 4 `text_config.layer_types` array. The
53/// repeating "5 sliding + 1 full" Gemma 3 pattern is just a special
54/// case of this richer per-layer schema.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum GemmaLayerType {
58    SlidingAttention,
59    FullAttention,
60}
61
62/// Nested rope_parameters block. Gemma 4 12B carries per-attention-kind
63/// rope parameters: sliding layers use `theta=1e4` with full rotation,
64/// full-attention layers use `theta=1e6` with `partial_rotary_factor`
65/// (p-RoPE rotating only the leading slice).
66#[derive(Debug, Clone, Copy, Deserialize, Default)]
67pub struct GemmaRopeParameters {
68    #[serde(default)]
69    pub partial_rotary_factor: Option<f32>,
70    #[serde(default)]
71    pub rope_theta: Option<f32>,
72    #[serde(default)]
73    pub rope_type: Option<GemmaRopeKind>,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
77#[serde(rename_all = "snake_case")]
78pub enum GemmaRopeKind {
79    #[default]
80    Default,
81    Proportional,
82    Linear,
83    Dynamic,
84}
85
86#[derive(Debug, Clone, Default, Deserialize)]
87pub struct GemmaRopeMap {
88    #[serde(default)]
89    pub sliding_attention: Option<GemmaRopeParameters>,
90    #[serde(default)]
91    pub full_attention: Option<GemmaRopeParameters>,
92}
93
94#[derive(Debug, Clone, Deserialize)]
95pub struct GemmaConfig {
96    #[serde(default)]
97    pub arch: GemmaArch,
98    pub vocab_size: usize,
99    pub hidden_size: usize,
100    pub intermediate_size: usize,
101    pub num_hidden_layers: usize,
102    pub num_attention_heads: usize,
103    pub num_key_value_heads: usize,
104    pub max_position_embeddings: usize,
105    #[serde(default = "default_rms_norm_eps")]
106    pub rms_norm_eps: f64,
107    #[serde(default = "default_rope_theta")]
108    pub rope_theta: f64,
109    #[serde(default)]
110    pub tie_word_embeddings: bool,
111    #[serde(default)]
112    pub attention_bias: bool,
113    #[serde(default)]
114    pub head_dim: Option<usize>,
115    #[serde(default)]
116    pub attn_logit_softcapping: Option<f32>,
117    #[serde(default)]
118    pub final_logit_softcapping: Option<f32>,
119    #[serde(default)]
120    pub sliding_window: Option<usize>,
121    #[serde(default)]
122    pub query_pre_attn_scalar: Option<f32>,
123    #[serde(default)]
124    pub effective_num_layers: Option<usize>,
125    #[serde(default)]
126    pub num_experts: usize,
127    #[serde(default)]
128    pub num_experts_used: usize,
129    #[serde(default)]
130    pub expert_ffn_size: usize,
131    #[serde(default = "default_expert_weights_scale")]
132    pub expert_weights_scale: f32,
133
134    // ── Gemma 4 unified additions ──────────────────────────────────
135    /// Per-layer attention kind. Empty for Gemma <=3 — fall back to
136    /// the strided pattern derived from `arch.sliding_window_stride`.
137    #[serde(default)]
138    pub layer_types: Vec<GemmaLayerType>,
139    /// Per-attention-kind rope settings. Empty for Gemma <=3.
140    #[serde(default)]
141    pub rope_parameters: GemmaRopeMap,
142    /// Head dim for full-attention (global) layers. `None` ⇒ reuse
143    /// the base `head_dim`. Gemma 4 12B sets this to 512 while the
144    /// sliding `head_dim` stays at 256.
145    #[serde(default)]
146    pub global_head_dim: Option<usize>,
147    /// Num KV heads for full-attention layers. `None` ⇒ reuse the
148    /// base `num_key_value_heads`. Gemma 4 12B sets this to 1.
149    #[serde(default)]
150    pub num_global_key_value_heads: Option<usize>,
151    /// When true (Gemma 4 12B), the K projection is reused as V at
152    /// load time — weights only ship `.k_proj` and `.v_proj` becomes
153    /// an alias.
154    #[serde(default)]
155    pub attention_k_eq_v: bool,
156    /// When `"vision"`, media placeholder spans use bidirectional
157    /// attention on sliding layers (Gemma 4 unified).
158    #[serde(default)]
159    pub use_bidirectional_attention: Option<String>,
160
161    // ── Gemma 4 E2B (mobile / edge) additions ──────────────────────
162    /// Per-Layer Embedding width per layer (Gemma 4 E2B: 256). `0` ⇒
163    /// the model has no Per-Layer Embeddings (flagship / GGUF path).
164    #[serde(default)]
165    pub hidden_size_per_layer_input: usize,
166    /// Vocabulary size of the per-layer embedding table. `0` ⇒ reuse
167    /// `vocab_size`. Gemma 4 E2B: 262144.
168    #[serde(default)]
169    pub vocab_size_per_layer_input: usize,
170    /// Number of trailing decoder layers that *reuse* (rather than
171    /// recompute) KV from an earlier same-type layer. `0` ⇒ every
172    /// layer computes its own KV (flagship). Gemma 4 E2B: 20.
173    #[serde(default)]
174    pub num_kv_shared_layers: usize,
175    /// When true, KV-shared layers double their MLP intermediate size
176    /// (Gemma 4 E2B: 6144 → 12288 on layers ≥ `first_kv_shared_layer`).
177    #[serde(default)]
178    pub use_double_wide_mlp: bool,
179    /// When true the (flagship / A4B) MoE block is active. Gemma 4 E2B
180    /// is dense (`false`).
181    #[serde(default)]
182    pub enable_moe_block: bool,
183}
184
185fn default_rms_norm_eps() -> f64 {
186    1e-6
187}
188fn default_rope_theta() -> f64 {
189    10_000.0
190}
191fn default_expert_weights_scale() -> f32 {
192    1.0
193}
194
195impl GemmaConfig {
196    pub fn from_file(path: &Path) -> anyhow::Result<Self> {
197        let data = std::fs::read_to_string(path)?;
198        // Gemma 4 unified (e.g. `google/gemma-4-12B`) nests the LM
199        // hyperparameters under `text_config` because the same file
200        // also carries vision + audio configs. Pick that subtree if
201        // it looks like the unified shape, otherwise stay flat.
202        let value: serde_json::Value = serde_json::from_str(&data)?;
203        let lm_value = match value.get("text_config") {
204            Some(tc) if tc.is_object() => tc.clone(),
205            _ => value.clone(),
206        };
207        let lm_value = normalize_hf_null_usize_fields(lm_value);
208        let mut cfg: Self = serde_json::from_value(lm_value)?;
209        if cfg.arch == GemmaArch::Gemma {
210            cfg.arch = infer_arch_from_json(&data);
211        }
212        Ok(cfg)
213    }
214
215    pub fn from_gguf(raw: &GgufFile) -> anyhow::Result<Self> {
216        gemma_cfg_from_gguf(raw)
217    }
218
219    pub fn head_dim(&self) -> usize {
220        self.head_dim
221            .unwrap_or(self.hidden_size / self.num_attention_heads)
222    }
223
224    pub fn kv_group_size(&self) -> usize {
225        self.num_attention_heads / self.num_key_value_heads
226    }
227
228    pub fn q_proj_dim(&self) -> usize {
229        self.num_attention_heads * self.head_dim()
230    }
231
232    pub fn kv_proj_dim(&self) -> usize {
233        self.num_key_value_heads * self.head_dim()
234    }
235
236    pub fn layer_style(&self) -> GemmaLayerStyle {
237        match self.arch {
238            GemmaArch::Gemma => GemmaLayerStyle::Gemma,
239            GemmaArch::Gemma2 => GemmaLayerStyle::Gemma2,
240            GemmaArch::Gemma3 => GemmaLayerStyle::Gemma3,
241            GemmaArch::Gemma4 => GemmaLayerStyle::Gemma4,
242        }
243    }
244
245    pub fn active_num_layers(&self) -> usize {
246        self.effective_num_layers.unwrap_or(self.num_hidden_layers)
247    }
248
249    pub fn is_moe(&self) -> bool {
250        self.arch == GemmaArch::Gemma4 && self.num_experts > 0
251    }
252
253    // ── Gemma 4 E2B: Per-Layer Embeddings + KV sharing ─────────────
254
255    /// Whether this checkpoint carries Per-Layer Embeddings (Gemma 4
256    /// E2B/E4B mobile). Drives the extra `embed_tokens_per_layer`,
257    /// `per_layer_*` projection/gate weights in the builder.
258    pub fn has_ple(&self) -> bool {
259        self.hidden_size_per_layer_input > 0
260    }
261
262    /// Width of one per-layer embedding slice (`0` when absent).
263    pub fn ple_width(&self) -> usize {
264        self.hidden_size_per_layer_input
265    }
266
267    /// Vocab of the per-layer embedding table (defaults to `vocab_size`).
268    pub fn ple_vocab_size(&self) -> usize {
269        if self.vocab_size_per_layer_input > 0 {
270            self.vocab_size_per_layer_input
271        } else {
272            self.vocab_size
273        }
274    }
275
276    /// Index of the first decoder layer that *reuses* (shares) KV from
277    /// an earlier layer. Layers `< first_kv_shared_layer` compute fresh
278    /// KV; layers `>=` it reuse. Returns `num_hidden_layers` (i.e. no
279    /// sharing) when `num_kv_shared_layers == 0`.
280    pub fn first_kv_shared_layer(&self) -> usize {
281        self.num_hidden_layers
282            .saturating_sub(self.num_kv_shared_layers)
283    }
284
285    /// Whether layer `i` reuses KV from an earlier same-type layer.
286    pub fn is_kv_shared_layer(&self, layer: usize) -> bool {
287        self.num_kv_shared_layers > 0 && layer >= self.first_kv_shared_layer()
288    }
289
290    /// The source layer whose KV a shared layer reuses: the last
291    /// *fresh* layer (`< first_kv_shared_layer`) of the **same**
292    /// attention kind (sliding vs full). Returns `layer` itself when
293    /// the layer is not shared (it computes its own KV).
294    pub fn kv_source_layer(&self, layer: usize) -> usize {
295        if !self.is_kv_shared_layer(layer) {
296            return layer;
297        }
298        let want_full = self.is_full_attention_layer(layer);
299        let boundary = self.first_kv_shared_layer();
300        (0..boundary)
301            .rev()
302            .find(|&src| self.is_full_attention_layer(src) == want_full)
303            .unwrap_or(layer)
304    }
305
306    /// MLP intermediate size for layer `i`. Gemma 4 E2B doubles the
307    /// intermediate width on KV-shared layers when `use_double_wide_mlp`
308    /// is set; all other layers use the base `intermediate_size`.
309    pub fn layer_intermediate_size(&self, layer: usize) -> usize {
310        if self.use_double_wide_mlp && self.is_kv_shared_layer(layer) {
311            self.intermediate_size * 2
312        } else {
313            self.intermediate_size
314        }
315    }
316
317    /// Gemma 4 unified: bidirectional attention inside vision/audio spans.
318    pub fn use_bidirectional_vision(&self) -> bool {
319        self.use_bidirectional_attention.as_deref() == Some("vision")
320    }
321
322    pub fn expert_ffn_dim(&self) -> usize {
323        if self.expert_ffn_size > 0 {
324            self.expert_ffn_size
325        } else {
326            self.intermediate_size
327        }
328    }
329
330    pub fn attn_score_scale(&self) -> Option<f32> {
331        match self.arch {
332            GemmaArch::Gemma => None,
333            // llama.cpp gemma4.cpp:11 "Gemma4 uses self.scaling = 1.0
334            // (no pre-attn scaling)". Q is RMS-normed per-head before
335            // attention so Q·K is already bounded — applying the
336            // standard 1/sqrt(head_dim) on top *crushes* the scores
337            // (12B head_dim=256 → 16× too small). Use unit scale.
338            GemmaArch::Gemma4 => Some(1.0),
339            GemmaArch::Gemma2 | GemmaArch::Gemma3 => {
340                if let Some(s) = self.query_pre_attn_scalar {
341                    Some(1.0 / s)
342                } else {
343                    Some(1.0 / (self.head_dim() as f32).sqrt())
344                }
345            }
346        }
347    }
348
349    /// Per-layer attention options driving the prefill self-attn block:
350    /// `(mask kind, softmax score scale, attention logit soft-cap)`.
351    /// The mask varies across Gemma variants:
352    ///
353    /// - Gemma 1 / no sliding window → all-causal.
354    /// - Gemma 2 → alternating sliding-window via [`gemma2_layer_mask`].
355    /// - Gemma 3 / 4 → strided pattern via
356    ///   [`gemma_strided_layer_mask`] (stride-6: every 6th layer is
357    ///   full causal, others are sliding-window).
358    pub fn layer_attn_options(&self, layer: usize) -> (MaskKind, Option<f32>, Option<f32>) {
359        let scale = self.attn_score_scale();
360        let softcap = self.attn_logit_softcapping;
361        let mask = match (self.arch, self.sliding_window) {
362            (_, None) => MaskKind::Causal,
363            (GemmaArch::Gemma2, Some(w)) => gemma2_layer_mask(layer, w),
364            (GemmaArch::Gemma3 | GemmaArch::Gemma4, Some(w)) => {
365                gemma_strided_layer_mask(layer, w, self.arch.sliding_window_stride())
366            }
367            _ => MaskKind::Causal,
368        };
369        (mask, scale, softcap)
370    }
371
372    #[cfg(test)]
373    pub(crate) fn tiny_test() -> Self {
374        Self {
375            arch: GemmaArch::Gemma,
376            vocab_size: 32,
377            hidden_size: 16,
378            intermediate_size: 32,
379            num_hidden_layers: 2,
380            num_attention_heads: 4,
381            num_key_value_heads: 2,
382            max_position_embeddings: 64,
383            rms_norm_eps: 1e-6,
384            rope_theta: 10_000.0,
385            tie_word_embeddings: true,
386            attention_bias: false,
387            head_dim: None,
388            attn_logit_softcapping: None,
389            final_logit_softcapping: None,
390            sliding_window: None,
391            query_pre_attn_scalar: None,
392            effective_num_layers: None,
393            num_experts: 0,
394            num_experts_used: 0,
395            expert_ffn_size: 0,
396            expert_weights_scale: 1.0,
397            layer_types: Vec::new(),
398            rope_parameters: GemmaRopeMap::default(),
399            global_head_dim: None,
400            num_global_key_value_heads: None,
401            attention_k_eq_v: false,
402            use_bidirectional_attention: None,
403            hidden_size_per_layer_input: 0,
404            vocab_size_per_layer_input: 0,
405            num_kv_shared_layers: 0,
406            use_double_wide_mlp: false,
407            enable_moe_block: false,
408        }
409    }
410
411    // ── Per-layer dispatch (Gemma 4 unified). ──────────────────────
412    //
413    // For Gemma 1/2/3 the `layer_types` array is empty and these
414    // helpers reduce to the existing strided pattern; for Gemma 4
415    // they read the explicit array so each layer can ship its own
416    // (head_dim, num_kv_heads, n_rot, rope_theta).
417
418    /// Whether layer `i` is a full-attention (global) layer rather
419    /// than a sliding-window one. Falls back to the strided pattern
420    /// (every `stride`-th layer is global) when `layer_types` is
421    /// unset.
422    pub fn is_full_attention_layer(&self, layer: usize) -> bool {
423        if !self.layer_types.is_empty() {
424            return matches!(
425                self.layer_types.get(layer),
426                Some(GemmaLayerType::FullAttention),
427            );
428        }
429        let stride = self.arch.sliding_window_stride();
430        stride > 1 && (layer + 1).is_multiple_of(stride)
431    }
432
433    /// Per-layer head_dim. Sliding layers always use the base
434    /// `head_dim`; full-attention layers use `global_head_dim` when
435    /// set (Gemma 4 12B: 512 vs base 256).
436    pub fn layer_head_dim(&self, layer: usize) -> usize {
437        if self.is_full_attention_layer(layer) {
438            self.global_head_dim.unwrap_or_else(|| self.head_dim())
439        } else {
440            self.head_dim()
441        }
442    }
443
444    /// Per-layer V-aliased-to-K flag. For Gemma 4 specifically:
445    /// SWA layers ship an independent v_proj weight; full-attention
446    /// layers (every 6th) omit v_proj and alias V to K. Other arches
447    /// fall back to the uniform `attention_k_eq_v`.
448    pub fn layer_k_eq_v(&self, layer: usize) -> bool {
449        if matches!(self.arch, GemmaArch::Gemma4) {
450            // HF `use_alternative_attention = attention_k_eq_v && !is_sliding`.
451            // The flagship (12B) sets `attention_k_eq_v=true` so full-attention
452            // layers alias V→K; E2B sets it false and ships a real v_proj on
453            // every layer, so it must NOT alias.
454            return self.attention_k_eq_v && self.is_full_attention_layer(layer);
455        }
456        self.attention_k_eq_v
457    }
458
459    /// Per-layer KV head count. Sliding layers use
460    /// `num_key_value_heads`; full-attention layers use
461    /// `num_global_key_value_heads` when set (Gemma 4 12B: 1 vs 8).
462    pub fn layer_num_kv_heads(&self, layer: usize) -> usize {
463        if self.is_full_attention_layer(layer) {
464            self.num_global_key_value_heads
465                .unwrap_or(self.num_key_value_heads)
466        } else {
467            self.num_key_value_heads
468        }
469    }
470
471    /// Number of leading per-head dimensions that get RoPE-rotated
472    /// in layer `i`. Returns `layer_head_dim` for "default" RoPE,
473    /// or `floor(partial_rotary_factor * head_dim)` for p-RoPE.
474    pub fn layer_n_rot(&self, layer: usize) -> usize {
475        let dh = self.layer_head_dim(layer);
476        let params = self.layer_rope_parameters(layer);
477        let kind = params
478            .and_then(|p| p.rope_type)
479            .unwrap_or(GemmaRopeKind::Default);
480        let factor = params.and_then(|p| p.partial_rotary_factor);
481        match (kind, factor) {
482            (GemmaRopeKind::Proportional, Some(f)) if f > 0.0 && f < 1.0 => {
483                ((dh as f32) * f).floor() as usize
484            }
485            _ => dh,
486        }
487    }
488
489    /// RoPE base frequency for layer `i`. Falls back to the
490    /// top-level `rope_theta` when the unified map omits the entry.
491    pub fn layer_rope_theta(&self, layer: usize) -> f64 {
492        self.layer_rope_parameters(layer)
493            .and_then(|p| p.rope_theta)
494            .map(|t| t as f64)
495            .unwrap_or(self.rope_theta)
496    }
497
498    fn layer_rope_parameters(&self, layer: usize) -> Option<&GemmaRopeParameters> {
499        if self.is_full_attention_layer(layer) {
500            self.rope_parameters.full_attention.as_ref()
501        } else {
502            self.rope_parameters.sliding_attention.as_ref()
503        }
504    }
505}
506
507/// HF dense Gemma 4 checkpoints use JSON `null` for unused MoE keys.
508fn normalize_hf_null_usize_fields(mut value: serde_json::Value) -> serde_json::Value {
509    let Some(obj) = value.as_object_mut() else {
510        return value;
511    };
512    for key in [
513        "num_experts",
514        "num_experts_used",
515        "top_k_experts",
516        "expert_ffn_size",
517        "moe_intermediate_size",
518        "hidden_size_per_layer_input",
519    ] {
520        if obj.get(key).is_some_and(|v| v.is_null()) {
521            obj.insert(key.to_string(), serde_json::Value::from(0usize));
522        }
523    }
524    value
525}
526
527fn infer_arch_from_json(raw: &str) -> GemmaArch {
528    // Detect Gemma 4 first — its unified config also contains a
529    // nested `gemma4_unified_text` model_type that we want to catch
530    // even when the outer `model_type` is `gemma4_unified` or the
531    // architecture is `Gemma4UnifiedForConditionalGeneration`.
532    if raw.contains("\"gemma4_unified\"")
533        || raw.contains("\"gemma4_unified_text\"")
534        || raw.contains("\"gemma4\"")
535        || raw.contains("\"gemma4moe\"")
536        || raw.contains("Gemma4UnifiedForConditionalGeneration")
537        || raw.contains("Gemma4ForCausalLM")
538    {
539        return GemmaArch::Gemma4;
540    }
541    if raw.contains("\"model_type\"") {
542        if raw.contains("\"gemma2\"") {
543            return GemmaArch::Gemma2;
544        }
545        if raw.contains("\"gemma3\"") {
546            return GemmaArch::Gemma3;
547        }
548    }
549    GemmaArch::Gemma
550}
551
552/// llama.cpp encodes Gemma 4 proportional (p-)RoPE in `rope_freqs.weight`:
553/// rotated dim pairs are `1.0`, unrotated pairs are ~`1e30` so RoPE skips
554/// them (see `conversion/gemma.py` `generate_extra_tensors`).
555fn infer_gemma4_full_partial_rotary(raw: &GgufFile, global_head_dim: usize) -> Option<f32> {
556    if global_head_dim == 0 {
557        return None;
558    }
559    let (factors, _) = raw.dequant_f32("rope_freqs.weight").ok()?;
560    if factors.is_empty() {
561        return None;
562    }
563    const SUPPRESS_THRESH: f32 = 1e20;
564    let rotated_pairs = factors.iter().filter(|&&f| f < SUPPRESS_THRESH).count();
565    if rotated_pairs == 0 || rotated_pairs >= factors.len() {
566        return None;
567    }
568    let n_rot = rotated_pairs * 2;
569    Some(n_rot as f32 / global_head_dim as f32)
570}
571
572/// Build [`GemmaRopeMap`] for Gemma 4 GGUF checkpoints.
573fn gemma4_rope_map_from_gguf(
574    raw: &GgufFile,
575    get_f32: &impl Fn(&str) -> Option<f32>,
576    get_u32_opt: &impl Fn(&str) -> Option<u32>,
577    swa_head_dim: Option<usize>,
578    global_head_dim: Option<usize>,
579) -> GemmaRopeMap {
580    let full_theta = get_f32("gemma.rope.freq_base");
581    let swa_theta = get_f32("gemma.rope.freq_base_swa");
582
583    let full_partial = global_head_dim.and_then(|ghd| {
584        infer_gemma4_full_partial_rotary(raw, ghd).or_else(|| {
585            // Flagship Gemma 4 12B/31B with distinct global head dim.
586            swa_head_dim.filter(|&swa| ghd > swa).map(|_| 0.25)
587        })
588    });
589
590    let swa_partial = match (swa_head_dim, get_u32_opt("gemma.rope.dimension_count_swa")) {
591        (Some(hd), Some(n_rot)) if (n_rot as usize) < hd => Some(n_rot as f32 / hd as f32),
592        _ => None,
593    };
594
595    GemmaRopeMap {
596        sliding_attention: swa_theta.map(|t| GemmaRopeParameters {
597            rope_theta: Some(t),
598            rope_type: Some(if swa_partial.is_some() {
599                GemmaRopeKind::Proportional
600            } else {
601                GemmaRopeKind::Default
602            }),
603            partial_rotary_factor: swa_partial,
604        }),
605        full_attention: full_theta.map(|t| GemmaRopeParameters {
606            rope_theta: Some(t),
607            rope_type: full_partial.map(|_| GemmaRopeKind::Proportional),
608            partial_rotary_factor: full_partial,
609        }),
610    }
611}
612
613pub fn gemma_cfg_from_gguf(raw: &GgufFile) -> anyhow::Result<GemmaConfig> {
614    let arch_tag = raw
615        .metadata
616        .get("general.architecture")
617        .and_then(MetaValue::as_str)
618        .unwrap_or("gemma");
619    let arch_prefix = arch_tag;
620    let arch = GemmaArch::from_gguf_tag(arch_tag);
621
622    let get_meta = |k: &str| -> Option<&MetaValue> {
623        raw.metadata.get(k).or_else(|| {
624            let suffix = k.strip_prefix("gemma.")?;
625            if arch_prefix == "gemma" {
626                None
627            } else {
628                let arch_key = format!("{arch_prefix}.{suffix}");
629                raw.metadata.get(&arch_key)
630            }
631        })
632    };
633    let get_u32 = |k: &str| -> anyhow::Result<u32> {
634        get_meta(k)
635            .and_then(MetaValue::as_u32)
636            .ok_or_else(|| anyhow::anyhow!("missing GGUF metadata key: {k}"))
637    };
638    // Some Gemma 4 GGUF writers encode per-layer attention as an
639    // Array instead of a scalar — e.g. `gemma4.attention.head_count_kv`
640    // can be `[60 × i32]` (16 for sliding layers, 4 for global). For
641    // the uniform-attention `GemmaConfig` we take the first array
642    // element (typically the dominant sliding-layer value) and let
643    // the per-layer overrides on `GemmaConfig` (e.g.
644    // `num_global_key_value_heads`) capture the global-layer
645    // exception. Falls back to scalar `as_u32` for older writers.
646    let get_first_u32 = |k: &str| -> anyhow::Result<u32> {
647        get_meta(k)
648            .and_then(MetaValue::as_first_u32)
649            .ok_or_else(|| anyhow::anyhow!("missing GGUF metadata key: {k}"))
650    };
651    let get_f32 = |k: &str| -> Option<f32> {
652        get_meta(k).and_then(|v| match v {
653            MetaValue::F32(x) => Some(*x),
654            _ => None,
655        })
656    };
657    let get_u32_opt = |k: &str| -> Option<u32> { get_meta(k).and_then(MetaValue::as_u32) };
658    let get_bool = |k: &str| -> Option<bool> {
659        get_meta(k).and_then(|v| match v {
660            MetaValue::Bool(b) => Some(*b),
661            _ => None,
662        })
663    };
664
665    let hidden_size = get_u32("gemma.embedding_length")? as usize;
666    let num_attention_heads = get_first_u32("gemma.attention.head_count")? as usize;
667    // Newer GGUF writers (Gemma 4) don't include `gemma.vocab_size`;
668    // infer it from the tokenizer.ggml.tokens array length when the
669    // scalar isn't present. Falls back to 256_000 only if neither
670    // path resolves.
671    let resolved_vocab_size: usize = get_u32("gemma.vocab_size")
672        .ok()
673        .map(|v| v as usize)
674        .or_else(|| {
675            raw.metadata
676                .get("tokenizer.ggml.tokens")
677                .and_then(MetaValue::as_array)
678                .map(|a| a.len())
679        })
680        .unwrap_or(256_000);
681    // Gemma 4 has TWO layer types with DIFFERENT head_dims:
682    //   - Sliding-window layers (majority): key_length_swa, e.g. 256
683    //   - Full-attention layers (every 6th): key_length, e.g. 512
684    // For non-Gemma-4 archs key_length is per-head directly.
685    let head_dim = if matches!(arch, GemmaArch::Gemma4) {
686        // SWA dim is the default; full layers get global_head_dim below.
687        get_first_u32("gemma.attention.key_length_swa")
688            .ok()
689            .or_else(|| get_first_u32("gemma.attention.key_length").ok())
690            .map(|v| v as usize)
691    } else {
692        get_first_u32("gemma.attention.key_length")
693            .ok()
694            .or_else(|| get_first_u32("gemma.rope.dimension_count").ok())
695            .map(|v| v as usize)
696    };
697
698    // Gemma 4: gather full-attention layer dims when distinct from SWA.
699    // The metadata stores head_count_kv as a 48-element array — find any
700    // value that differs from the first element; that's the global head
701    // count. Same for key_length (full head_dim).
702    let global_head_dim = if matches!(arch, GemmaArch::Gemma4) {
703        let swa = head_dim.unwrap_or(0);
704        let full = get_first_u32("gemma.attention.key_length")
705            .ok()
706            .map(|v| v as usize);
707        match full {
708            Some(f) if f != swa => Some(f),
709            _ => None,
710        }
711    } else {
712        None
713    };
714    let num_global_key_value_heads = if matches!(arch, GemmaArch::Gemma4) {
715        let sliding_kv = get_first_u32("gemma.attention.head_count_kv")
716            .ok()
717            .map(|v| v as usize);
718        let kv_array = raw
719            .metadata
720            .get(&format!("{arch_prefix}.attention.head_count_kv"))
721            .or_else(|| raw.metadata.get("gemma.attention.head_count_kv"))
722            .and_then(MetaValue::as_array);
723        if let (Some(swa_kv), Some(arr)) = (sliding_kv, kv_array) {
724            arr.iter().find_map(|v| match v {
725                MetaValue::I32(n) if (*n as usize) != swa_kv => Some(*n as usize),
726                MetaValue::U32(n) if (*n as usize) != swa_kv => Some(*n as usize),
727                _ => None,
728            })
729        } else {
730            None
731        }
732    } else {
733        None
734    };
735
736    Ok(GemmaConfig {
737        arch,
738        vocab_size: resolved_vocab_size,
739        hidden_size,
740        intermediate_size: get_u32("gemma.feed_forward_length")? as usize,
741        num_hidden_layers: get_u32("gemma.block_count")? as usize,
742        num_attention_heads,
743        num_key_value_heads: get_first_u32("gemma.attention.head_count_kv")? as usize,
744        max_position_embeddings: get_u32("gemma.context_length").unwrap_or(8192) as usize,
745        rms_norm_eps: get_f32("gemma.attention.layer_norm_rms_epsilon").unwrap_or(1e-6) as f64,
746        rope_theta: get_f32("gemma.rope.freq_base").unwrap_or(10_000.0) as f64,
747        tie_word_embeddings: get_bool("gemma.tie_word_embeddings").unwrap_or(true),
748        attention_bias: get_bool("gemma.attention.bias").unwrap_or(false),
749        head_dim,
750        attn_logit_softcapping: get_f32("gemma.attn_logit_softcapping"),
751        final_logit_softcapping: get_f32("gemma.final_logit_softcapping"),
752        sliding_window: get_u32("gemma.attention.sliding_window")
753            .ok()
754            .map(|v| v as usize),
755        query_pre_attn_scalar: get_f32("gemma.attention.query_pre_attn_scalar"),
756        effective_num_layers: get_u32("gemma.block_count_effective")
757            .ok()
758            .map(|v| v as usize),
759        num_experts: get_u32("gemma.expert_count").unwrap_or(0) as usize,
760        num_experts_used: get_u32("gemma.expert_used_count").unwrap_or(0) as usize,
761        expert_ffn_size: get_u32("gemma.expert_feed_forward_length").unwrap_or(0) as usize,
762        expert_weights_scale: get_f32("gemma.expert_weights_scale").unwrap_or(1.0),
763        // GGUF doesn't carry the Gemma 4 unified per-layer schema
764        // yet; the dense path falls back to the strided pattern and
765        // uniform head dims that match every Gemma 4 GGUF currently
766        // emitted by llama.cpp.
767        layer_types: Vec::new(),
768        // Gemma 4 ships per-attention-kind rope params in the GGUF:
769        //   gemma4.rope.freq_base       = 1e6 (full-attention layers)
770        //   gemma4.rope.freq_base_swa   = 1e4 (sliding-window layers)
771        // Without populating this, layer_rope_theta returns the global
772        // freq_base for ALL layers — SWA layers RoPE with the wrong
773        // base → wrong K rotation → bad attention scores. Build the
774        // GemmaRopeMap from the metadata so layer_rope_theta(swa) and
775        // layer_rope_theta(full) split correctly.
776        rope_parameters: if matches!(arch, GemmaArch::Gemma4) {
777            gemma4_rope_map_from_gguf(raw, &get_f32, &get_u32_opt, head_dim, global_head_dim)
778        } else {
779            GemmaRopeMap::default()
780        },
781        global_head_dim,
782        num_global_key_value_heads,
783        // For Gemma 4, V-aliased-to-K is PER-LAYER: only full-attention
784        // layers omit v_proj. The base scalar stays true (matches the
785        // common case + existing tests + Gemma 4 12B/31B unified
786        // checkpoints where the dominant pattern is V-as-K). Callers in
787        // the graph builder should consult `cfg.layer_k_eq_v(i)` to
788        // pick up the SWA-layer V-independent case.
789        attention_k_eq_v: matches!(arch, GemmaArch::Gemma4),
790        use_bidirectional_attention: None,
791        // Per-Layer Embeddings + KV sharing are Gemma 4 E2B (mobile)
792        // features that only ship in the HF safetensors checkpoint, not
793        // in any GGUF emitted by llama.cpp today. Default them off so the
794        // GGUF path keeps the flagship dense behavior.
795        hidden_size_per_layer_input: get_u32("gemma.embedding_length_per_layer_input").unwrap_or(0)
796            as usize,
797        vocab_size_per_layer_input: 0,
798        num_kv_shared_layers: get_u32("gemma.attention.shared_kv_layers").unwrap_or(0) as usize,
799        use_double_wide_mlp: get_bool("gemma.use_double_wide_mlp").unwrap_or(false),
800        enable_moe_block: get_u32("gemma.expert_count").unwrap_or(0) > 0,
801    })
802}
803
804#[cfg(test)]
805mod tests {
806    use super::*;
807
808    /// Trimmed copy of `google/gemma-4-12B`'s `config.json` — only the
809    /// fields the loader actually consumes plus the surrounding shape
810    /// (top-level `model_type`, nested `text_config`) that proves we
811    /// unwrap the unified layout correctly.
812    const GEMMA_4_12B_CONFIG: &str = r#"{
813      "architectures": ["Gemma4UnifiedForConditionalGeneration"],
814      "model_type": "gemma4_unified",
815      "tie_word_embeddings": true,
816      "text_config": {
817        "model_type": "gemma4_unified_text",
818        "vocab_size": 262144,
819        "hidden_size": 3840,
820        "intermediate_size": 15360,
821        "num_hidden_layers": 48,
822        "num_attention_heads": 16,
823        "num_key_value_heads": 8,
824        "num_global_key_value_heads": 1,
825        "head_dim": 256,
826        "global_head_dim": 512,
827        "attention_k_eq_v": true,
828        "max_position_embeddings": 131072,
829        "rms_norm_eps": 1e-6,
830        "tie_word_embeddings": true,
831        "attention_bias": false,
832        "final_logit_softcapping": 30.0,
833        "sliding_window": 1024,
834        "layer_types": [
835          "sliding_attention","sliding_attention","sliding_attention","sliding_attention","sliding_attention","full_attention",
836          "sliding_attention","sliding_attention","sliding_attention","sliding_attention","sliding_attention","full_attention",
837          "sliding_attention","sliding_attention","sliding_attention","sliding_attention","sliding_attention","full_attention",
838          "sliding_attention","sliding_attention","sliding_attention","sliding_attention","sliding_attention","full_attention",
839          "sliding_attention","sliding_attention","sliding_attention","sliding_attention","sliding_attention","full_attention",
840          "sliding_attention","sliding_attention","sliding_attention","sliding_attention","sliding_attention","full_attention",
841          "sliding_attention","sliding_attention","sliding_attention","sliding_attention","sliding_attention","full_attention",
842          "sliding_attention","sliding_attention","sliding_attention","sliding_attention","sliding_attention","full_attention"
843        ],
844        "rope_parameters": {
845          "full_attention":    { "partial_rotary_factor": 0.25, "rope_theta": 1000000.0, "rope_type": "proportional" },
846          "sliding_attention": { "rope_theta": 10000.0, "rope_type": "default" }
847        }
848      }
849    }"#;
850
851    #[test]
852    fn gemma_4_12b_unified_config_parses_text_subtree() {
853        let dir = std::env::temp_dir();
854        let path = dir.join("rlx_gemma_gemma4_12b_test_config.json");
855        std::fs::write(&path, GEMMA_4_12B_CONFIG).unwrap();
856        let cfg = GemmaConfig::from_file(&path).unwrap();
857        std::fs::remove_file(&path).ok();
858
859        assert_eq!(cfg.arch, GemmaArch::Gemma4);
860        assert_eq!(cfg.vocab_size, 262_144);
861        assert_eq!(cfg.hidden_size, 3840);
862        assert_eq!(cfg.intermediate_size, 15_360);
863        assert_eq!(cfg.num_hidden_layers, 48);
864        assert_eq!(cfg.num_attention_heads, 16);
865        assert_eq!(cfg.num_key_value_heads, 8);
866        assert_eq!(cfg.head_dim(), 256);
867        assert_eq!(cfg.global_head_dim, Some(512));
868        assert_eq!(cfg.num_global_key_value_heads, Some(1));
869        assert!(cfg.attention_k_eq_v);
870        assert_eq!(cfg.sliding_window, Some(1024));
871        assert_eq!(cfg.final_logit_softcapping, Some(30.0));
872        assert!(cfg.tie_word_embeddings);
873        assert_eq!(cfg.layer_types.len(), 48);
874        // Stride-6 sliding-window pattern carried over from Gemma 3.
875        assert_eq!(cfg.arch.sliding_window_stride(), 6);
876    }
877
878    /// Regression: Gemma 4 31B Q4_K_M GGUF (unsloth) encodes
879    /// `gemma4.attention.head_count_kv` as an `Array[60 × i32]`
880    /// (per-layer KV head count) and omits `gemma.vocab_size`
881    /// entirely — vocab_size comes from `tokenizer.ggml.tokens`
882    /// array length. `gemma_cfg_from_gguf` must:
883    ///   1. Read scalar/array uniformly via `MetaValue::as_first_u32`.
884    ///   2. Fall back to tokens-array length when the scalar is missing.
885    ///   3. Set `attention_k_eq_v = true` automatically on Gemma 4.
886    #[test]
887    fn gemma4_gguf_per_layer_array_and_tokens_vocab() {
888        use rlx_gguf::{GgufFile, GgufWriter, MetaValue};
889        let mut w = GgufWriter::new();
890        // Smallest field set the loader needs to succeed:
891        w.set_meta("general.architecture", MetaValue::String("gemma4".into()));
892        w.set_meta("gemma4.embedding_length", MetaValue::U32(5376));
893        w.set_meta("gemma4.feed_forward_length", MetaValue::U32(21_504));
894        w.set_meta("gemma4.block_count", MetaValue::U32(60));
895        // head_count: U32 scalar (matches unsloth's layout = global KV heads = 4).
896        w.set_meta("gemma4.attention.head_count", MetaValue::U32(4));
897        // head_count_kv: Array of per-layer i32 — first element is
898        // the sliding-layer KV count (16).
899        let layer_kv: Vec<MetaValue> = (0..60)
900            .map(|i| MetaValue::I32(if i == 5 { 4 } else { 16 }))
901            .collect();
902        w.set_meta("gemma4.attention.head_count_kv", MetaValue::Array(layer_kv));
903        // Tokens array — implies vocab_size = 262_144 without the
904        // scalar `gemma.vocab_size`.
905        let tokens: Vec<MetaValue> = (0..262_144)
906            .map(|_| MetaValue::String(String::new()))
907            .collect();
908        w.set_meta("tokenizer.ggml.tokens", MetaValue::Array(tokens));
909
910        let path = std::env::temp_dir().join("rlx_gemma_gemma4_array_kv_test.gguf");
911        w.write_to_path(&path).unwrap();
912        let raw = GgufFile::from_path(&path).unwrap();
913        std::fs::remove_file(&path).ok();
914
915        let cfg = gemma_cfg_from_gguf(&raw).unwrap();
916        assert_eq!(cfg.arch, GemmaArch::Gemma4);
917        assert_eq!(cfg.vocab_size, 262_144, "vocab from tokens-array length");
918        assert_eq!(cfg.hidden_size, 5376);
919        assert_eq!(cfg.intermediate_size, 21_504);
920        assert_eq!(cfg.num_hidden_layers, 60);
921        assert_eq!(cfg.num_attention_heads, 4);
922        // First array element = 16 (sliding-layer KV heads).
923        assert_eq!(
924            cfg.num_key_value_heads, 16,
925            "as_first_u32 should pick array[0], not panic on Array variant"
926        );
927        // Gemma 4 implies attention_k_eq_v.
928        assert!(cfg.attention_k_eq_v, "Gemma 4 should default k_eq_v=true");
929    }
930
931    #[test]
932    fn hf_null_moe_fields_default_to_zero() {
933        let json = r#"{"num_experts": null, "top_k_experts": null}"#;
934        let v = normalize_hf_null_usize_fields(serde_json::from_str(json).unwrap());
935        let obj = v.as_object().unwrap();
936        assert_eq!(obj["num_experts"], 0);
937        assert_eq!(obj["top_k_experts"], 0);
938    }
939
940    #[test]
941    fn infer_gemma4_partial_rotary_from_rope_freqs_pattern() {
942        use rlx_gguf::{GgmlType, GgufFile, GgufWriter, MetaValue};
943        let mut w = GgufWriter::new();
944        w.set_meta("general.architecture", MetaValue::String("gemma4".into()));
945        w.set_meta("gemma4.embedding_length", MetaValue::U32(3840));
946        w.set_meta("gemma4.feed_forward_length", MetaValue::U32(15_360));
947        w.set_meta("gemma4.block_count", MetaValue::U32(48));
948        w.set_meta("gemma4.attention.head_count", MetaValue::U32(16));
949        w.set_meta("gemma4.attention.head_count_kv", MetaValue::U32(8));
950        w.set_meta("gemma4.context_length", MetaValue::U32(8192));
951        w.set_meta("gemma4.rope.freq_base", MetaValue::F32(1_000_000.0));
952        w.set_meta("gemma4.rope.freq_base_swa", MetaValue::F32(10_000.0));
953        w.set_meta("gemma4.rope.dimension_count", MetaValue::U32(512));
954        w.set_meta("gemma4.rope.dimension_count_swa", MetaValue::U32(256));
955        w.set_meta("gemma4.attention.key_length", MetaValue::U32(512));
956        w.set_meta("gemma4.attention.key_length_swa", MetaValue::U32(256));
957        // Mimic llama.cpp Gemma 4 p-RoPE: 64 rotated pairs + 192 suppressed.
958        let mut factors = vec![1.0f32; 64];
959        factors.extend(std::iter::repeat_n(1e30f32, 192));
960        let factor_bytes: Vec<u8> = factors.iter().flat_map(|f| f.to_le_bytes()).collect();
961        w.add_tensor_bytes("rope_freqs.weight", vec![256], GgmlType::F32, factor_bytes)
962            .unwrap();
963        let path = std::env::temp_dir().join("rlx_gemma_gemma4_rope_freqs_test.gguf");
964        w.write_to_path(&path).unwrap();
965        let raw = GgufFile::from_path(&path).unwrap();
966        std::fs::remove_file(&path).ok();
967
968        let cfg = gemma_cfg_from_gguf(&raw).unwrap();
969        assert_eq!(cfg.arch, GemmaArch::Gemma4);
970        assert_eq!(cfg.layer_n_rot(5), 128, "full layer p-RoPE from rope_freqs");
971        assert_eq!(cfg.layer_n_rot(0), 256, "swa layer full rotation");
972        let full = cfg.rope_parameters.full_attention.as_ref().unwrap();
973        assert_eq!(full.partial_rotary_factor, Some(0.25));
974        assert_eq!(full.rope_type, Some(GemmaRopeKind::Proportional));
975    }
976
977    #[test]
978    fn gemma_4_12b_per_layer_dispatch() {
979        let dir = std::env::temp_dir();
980        let path = dir.join("rlx_gemma_gemma4_12b_dispatch_config.json");
981        std::fs::write(&path, GEMMA_4_12B_CONFIG).unwrap();
982        let cfg = GemmaConfig::from_file(&path).unwrap();
983        std::fs::remove_file(&path).ok();
984
985        // Sliding layer 0 — base shapes + full rotary on theta=1e4.
986        assert!(!cfg.is_full_attention_layer(0));
987        assert_eq!(cfg.layer_head_dim(0), 256);
988        assert_eq!(cfg.layer_num_kv_heads(0), 8);
989        assert_eq!(cfg.layer_n_rot(0), 256);
990        assert!((cfg.layer_rope_theta(0) - 10_000.0).abs() < 1e-3);
991
992        // Full-attention layer 5 (1-indexed: 6th layer) — global
993        // shapes, p-RoPE (0.25 of head_dim_full=512 → 128), theta=1e6.
994        assert!(cfg.is_full_attention_layer(5));
995        assert_eq!(cfg.layer_head_dim(5), 512);
996        assert_eq!(cfg.layer_num_kv_heads(5), 1);
997        assert_eq!(cfg.layer_n_rot(5), 128);
998        assert!((cfg.layer_rope_theta(5) - 1_000_000.0).abs() < 1e-3);
999
1000        // Last layer (index 47, 1-indexed 48) is also full-attention.
1001        assert!(cfg.is_full_attention_layer(47));
1002    }
1003
1004    #[test]
1005    fn pre_gemma4_archs_keep_uniform_layer_shape() {
1006        // Without `layer_types` / `rope_parameters` the per-layer
1007        // accessors collapse to the base values so Gemma 3 / 2 / 1
1008        // continue to round-trip the existing flow.
1009        let mut cfg = GemmaConfig::tiny_test();
1010        cfg.arch = GemmaArch::Gemma3;
1011        cfg.head_dim = Some(64);
1012        cfg.num_key_value_heads = 2;
1013        cfg.rope_theta = 1_000.0;
1014        for i in 0..cfg.num_hidden_layers {
1015            assert_eq!(cfg.layer_head_dim(i), 64);
1016            assert_eq!(cfg.layer_num_kv_heads(i), 2);
1017            assert_eq!(cfg.layer_n_rot(i), 64);
1018            assert!((cfg.layer_rope_theta(i) - 1_000.0).abs() < 1e-3);
1019        }
1020    }
1021
1022    #[test]
1023    fn infer_arch_picks_up_gemma4_markers() {
1024        assert_eq!(
1025            infer_arch_from_json(r#"{"model_type":"gemma4_unified"}"#),
1026            GemmaArch::Gemma4,
1027        );
1028        assert_eq!(
1029            infer_arch_from_json(r#"{"architectures":["Gemma4UnifiedForConditionalGeneration"]}"#),
1030            GemmaArch::Gemma4,
1031        );
1032        assert_eq!(
1033            infer_arch_from_json(r#"{"model_type":"gemma3"}"#),
1034            GemmaArch::Gemma3,
1035        );
1036    }
1037
1038    /// Trimmed `google/gemma-4-E2B-it` text_config — exercises the
1039    /// Per-Layer-Embedding + KV-sharing + double-wide-MLP fields and the
1040    /// helpers that drive the builder.
1041    const GEMMA_4_E2B_CONFIG: &str = r#"{
1042      "model_type": "gemma4",
1043      "text_config": {
1044        "model_type": "gemma4_text",
1045        "vocab_size": 262144,
1046        "hidden_size": 1536,
1047        "intermediate_size": 6144,
1048        "num_hidden_layers": 35,
1049        "num_attention_heads": 8,
1050        "num_key_value_heads": 1,
1051        "head_dim": 256,
1052        "global_head_dim": 512,
1053        "num_kv_shared_layers": 20,
1054        "hidden_size_per_layer_input": 256,
1055        "vocab_size_per_layer_input": 262144,
1056        "use_double_wide_mlp": true,
1057        "enable_moe_block": false,
1058        "max_position_embeddings": 131072,
1059        "rms_norm_eps": 1e-6,
1060        "final_logit_softcapping": 30.0,
1061        "sliding_window": 512,
1062        "tie_word_embeddings": false,
1063        "layer_types": [
1064          "sliding_attention","sliding_attention","sliding_attention","sliding_attention","full_attention",
1065          "sliding_attention","sliding_attention","sliding_attention","sliding_attention","full_attention",
1066          "sliding_attention","sliding_attention","sliding_attention","sliding_attention","full_attention",
1067          "sliding_attention","sliding_attention","sliding_attention","sliding_attention","full_attention",
1068          "sliding_attention","sliding_attention","sliding_attention","sliding_attention","full_attention",
1069          "sliding_attention","sliding_attention","sliding_attention","sliding_attention","full_attention",
1070          "sliding_attention","sliding_attention","sliding_attention","sliding_attention","full_attention"
1071        ],
1072        "rope_parameters": {
1073          "full_attention": {"partial_rotary_factor": 0.25, "rope_theta": 1000000.0, "rope_type": "proportional"},
1074          "sliding_attention": {"rope_theta": 10000.0, "rope_type": "default"}
1075        }
1076      }
1077    }"#;
1078
1079    #[test]
1080    fn gemma_4_e2b_config_parses_ple_and_kv_sharing() {
1081        let cfg: GemmaConfig = {
1082            let value: serde_json::Value = serde_json::from_str(GEMMA_4_E2B_CONFIG).unwrap();
1083            let lm = value.get("text_config").cloned().unwrap();
1084            let mut c: GemmaConfig =
1085                serde_json::from_value(normalize_hf_null_usize_fields(lm)).unwrap();
1086            c.arch = infer_arch_from_json(GEMMA_4_E2B_CONFIG);
1087            c
1088        };
1089        assert_eq!(cfg.arch, GemmaArch::Gemma4);
1090        // PLE
1091        assert!(cfg.has_ple());
1092        assert_eq!(cfg.ple_width(), 256);
1093        assert_eq!(cfg.ple_vocab_size(), 262144);
1094        // KV sharing: 35 - 20 = 15
1095        assert_eq!(cfg.first_kv_shared_layer(), 15);
1096        for i in 0..15 {
1097            assert!(!cfg.is_kv_shared_layer(i), "layer {i} should be fresh");
1098            assert_eq!(cfg.kv_source_layer(i), i);
1099        }
1100        for i in 15..35 {
1101            assert!(cfg.is_kv_shared_layer(i), "layer {i} should be shared");
1102        }
1103        // Full-attention layers sit at 4,9,14,...; last fresh full = 14,
1104        // last fresh sliding = 13.
1105        assert!(cfg.is_full_attention_layer(19));
1106        assert_eq!(
1107            cfg.kv_source_layer(19),
1108            14,
1109            "shared full reuses last fresh full"
1110        );
1111        assert_eq!(cfg.kv_source_layer(34), 14);
1112        assert!(!cfg.is_full_attention_layer(15));
1113        assert_eq!(
1114            cfg.kv_source_layer(15),
1115            13,
1116            "shared sliding reuses last fresh sliding"
1117        );
1118        assert_eq!(cfg.kv_source_layer(20), 13);
1119        // Double-wide MLP only on shared layers.
1120        assert_eq!(cfg.layer_intermediate_size(0), 6144);
1121        assert_eq!(cfg.layer_intermediate_size(14), 6144);
1122        assert_eq!(cfg.layer_intermediate_size(15), 12288);
1123        assert_eq!(cfg.layer_intermediate_size(34), 12288);
1124    }
1125
1126    #[test]
1127    fn non_e2b_config_has_no_ple_or_sharing() {
1128        let cfg = GemmaConfig::tiny_test();
1129        assert!(!cfg.has_ple());
1130        assert_eq!(cfg.first_kv_shared_layer(), cfg.num_hidden_layers);
1131        assert!(!cfg.is_kv_shared_layer(0));
1132        assert_eq!(cfg.kv_source_layer(0), 0);
1133        assert_eq!(cfg.layer_intermediate_size(0), cfg.intermediate_size);
1134    }
1135}