Skip to main content

rlx_gemma/
flow.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//! Fluent Gemma model assembly — tier-0 reference for `rlx-flow`.
17//!
18//! ```rust,ignore
19//! use rlx_models::gemma::GemmaFlow;
20//!
21//! // Prefill logits for the last token
22//! let built = GemmaFlow::for_prefill(&cfg, 1, 128)
23//!     .last_token_logits()
24//!     .profile_near(&weights_path)
25//!     .build(&mut weights)?;
26//!
27//! // Decode step with KV side outputs
28//! let built = GemmaFlow::for_decode(&cfg, 1, 256)
29//!     .custom_mask()
30//!     .profile_decode()
31//!     .build(&mut weights)?;
32//!
33//! // Override one layer while keeping the rest of the recipe
34//! let built = GemmaFlow::for_prefill(&cfg, 1, 128)
35//!     .layer(|ctx| {
36//!         if ctx.index() == 0 {
37//!             ctx.default_stage() // or FlowStage::Custom(...)
38//!         } else {
39//!             ctx.default_stage()
40//!         }
41//!     })
42//!     .build(&mut weights)?;
43//! ```
44
45use std::collections::HashMap;
46use std::fmt;
47use std::path::Path;
48use std::sync::Arc;
49
50use anyhow::Result;
51use rlx_flow::blocks::{
52    DecodeRopeParamsStage, EmbedScaleStage, GemmaDecodeLayerSpec, GemmaDecodeLayerStage,
53    GemmaLayerStyle, GemmaRmsNormStage, LmHeadStage, LogitSoftcapStage, RopeTablesStage,
54    gemma_attn_spec, gemma_prefill_layer_composed,
55};
56use rlx_flow::{BuiltModel, CompileProfile, FlowStage, ModelFlow, SideOutputs};
57use rlx_ir::dynamic::sym;
58use rlx_ir::hir::HirModule;
59use rlx_ir::shape::Dim;
60use rlx_ir::{DType, Graph, Shape};
61
62use super::config::{GemmaArch, GemmaConfig};
63use super::rope::{build_rope_tables, resolve_inv_freq};
64use rlx_core::flow_bridge::{WeightLoaderSource, load_compile_profile};
65use rlx_core::weight_loader::WeightLoader;
66
67/// Tier-1 profile file name colocated with weights.
68pub const GEMMA_PROFILE_FILE: &str = "gemma.rlx.toml";
69
70/// Resolve compile profile from `gemma.rlx.toml` in the weights directory.
71pub fn gemma_profile_near_weights(weights: &Path, decode: bool) -> CompileProfile {
72    let default = if decode {
73        CompileProfile::gemma_decode()
74    } else {
75        CompileProfile::gemma_prefill()
76    };
77    let dir = weights.parent().unwrap_or_else(|| Path::new("."));
78    load_compile_profile(&dir.join(GEMMA_PROFILE_FILE), default)
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum GemmaMode {
83    Prefill,
84    Decode,
85}
86
87/// Per-layer context for `.layer()` overrides — defaults preserve stock Gemma blocks.
88pub enum GemmaLayerCtx<'a> {
89    Prefill {
90        index: usize,
91        style: GemmaLayerStyle,
92        attn: rlx_flow::blocks::SelfAttnPrefillSpec,
93        kv_sink: &'a SideOutputs,
94        export_kv: bool,
95        head_dim: usize,
96        eps: f32,
97    },
98    Decode {
99        index: usize,
100        spec: GemmaDecodeLayerSpec,
101        kv_out: &'a SideOutputs,
102        /// EAGLE3-style pre-attention-norm input tap. `Some` only on
103        /// layers requested via [`GemmaFlow::with_aux_hidden_outputs`].
104        aux_in: Option<&'a SideOutputs>,
105    },
106}
107
108impl GemmaLayerCtx<'_> {
109    pub fn index(&self) -> usize {
110        match self {
111            Self::Prefill { index, .. } | Self::Decode { index, .. } => *index,
112        }
113    }
114
115    pub fn default_stage(&self) -> FlowStage {
116        match self {
117            Self::Prefill {
118                index,
119                style,
120                attn,
121                kv_sink,
122                export_kv,
123                head_dim: _,
124                eps,
125            } => gemma_prefill_layer_composed(
126                *index,
127                *style,
128                attn.clone(),
129                *eps,
130                if *export_kv {
131                    Some(kv_sink.inner())
132                } else {
133                    None
134                },
135            ),
136            Self::Decode {
137                index,
138                spec,
139                kv_out,
140                aux_in,
141            } => {
142                let mut stage = GemmaDecodeLayerStage::layer(*index, spec.clone(), kv_out.inner());
143                if let Some(sink) = aux_in {
144                    stage = stage.with_aux_input_tap(sink.inner());
145                }
146                FlowStage::Named {
147                    name: format!("layer{index}"),
148                    inner: Arc::new(FlowStage::GemmaDecodeLayer(stage)),
149                }
150            }
151        }
152    }
153}
154
155type LayerFn = Arc<dyn Fn(GemmaLayerCtx<'_>) -> FlowStage + Send + Sync>;
156type FlowPatchFn = Arc<dyn Fn(ModelFlow) -> ModelFlow + Send + Sync>;
157
158/// Fluent Gemma flow builder — reads config once, chain modifiers, then `build`.
159///
160/// ```rust,ignore
161/// use rlx_models::gemma::{GemmaConfig, GemmaFlow};
162///
163/// let built = GemmaFlow::new(&cfg)
164///     .prefill()
165///     .batch(1)
166///     .seq(128)
167///     .lm_head()
168///     .last_token_logits()
169///     .build(&mut weights)?;
170/// ```
171#[derive(Clone)]
172pub struct GemmaFlow<'a> {
173    cfg: &'a GemmaConfig,
174    mode: GemmaMode,
175    batch: usize,
176    seq: usize,
177    past_seq: usize,
178    dynamic_seq: bool,
179    dynamic_past: bool,
180    with_lm_head: bool,
181    with_kv_outputs: bool,
182    /// EAGLE3 layer-input tap: sorted, unique layer indices whose
183    /// pre-attention-norm hidden states should be exported as extra
184    /// graph outputs (one tensor per layer, in ascending order).
185    aux_hidden_layer_ids: Vec<usize>,
186    last_logits_only: bool,
187    use_custom_mask: bool,
188    profile: Option<CompileProfile>,
189    before_layers: Vec<FlowStage>,
190    after_layers: Vec<FlowStage>,
191    layer_fn: Option<LayerFn>,
192    flow_patch: Option<FlowPatchFn>,
193    /// Prefill from fused `inputs_embeds` (`prefill_hidden` input) instead of token ids.
194    prefill_hidden: bool,
195    /// Sliding layers read additive `attn_bias` for vision bidirectional spans.
196    media_attn_bias: bool,
197}
198
199impl fmt::Debug for GemmaFlow<'_> {
200    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201        f.debug_struct("GemmaFlow")
202            .field("mode", &self.mode)
203            .field("batch", &self.batch)
204            .field("seq", &self.seq)
205            .field("past_seq", &self.past_seq)
206            .field("dynamic_seq", &self.dynamic_seq)
207            .field("dynamic_past", &self.dynamic_past)
208            .field("with_lm_head", &self.with_lm_head)
209            .field("with_kv_outputs", &self.with_kv_outputs)
210            .field("last_logits_only", &self.last_logits_only)
211            .field("use_custom_mask", &self.use_custom_mask)
212            .field("profile", &self.profile)
213            .field("before_layers", &self.before_layers.len())
214            .field("after_layers", &self.after_layers.len())
215            .field("layer_fn", &self.layer_fn.is_some())
216            .field("flow_patch", &self.flow_patch.is_some())
217            .finish_non_exhaustive()
218    }
219}
220
221impl<'a> GemmaFlow<'a> {
222    pub fn new(cfg: &'a GemmaConfig) -> Self {
223        Self {
224            cfg,
225            mode: GemmaMode::Prefill,
226            batch: 1,
227            seq: 128,
228            past_seq: 0,
229            dynamic_seq: false,
230            dynamic_past: false,
231            with_lm_head: false,
232            with_kv_outputs: false,
233            aux_hidden_layer_ids: Vec::new(),
234            last_logits_only: false,
235            use_custom_mask: false,
236            profile: None,
237            before_layers: Vec::new(),
238            after_layers: Vec::new(),
239            layer_fn: None,
240            flow_patch: None,
241            prefill_hidden: false,
242            media_attn_bias: false,
243        }
244    }
245
246    /// Skip token embedding — feed pre-scaled hidden states at `prefill_hidden`.
247    pub fn prefill_from_hidden(mut self) -> Self {
248        self.prefill_hidden = true;
249        self
250    }
251
252    /// Add `attn_bias` input and bidirectional self-attn on sliding layers.
253    pub fn prefill_media_attn_bias(mut self) -> Self {
254        self.media_attn_bias = true;
255        self
256    }
257
258    /// Prefill recipe with common batch/seq defaults.
259    pub fn for_prefill(cfg: &'a GemmaConfig, batch: usize, seq: usize) -> Self {
260        Self::new(cfg).prefill().batch(batch).seq(seq)
261    }
262
263    /// Decode recipe with common batch/past defaults (includes LM head).
264    pub fn for_decode(cfg: &'a GemmaConfig, batch: usize, past_seq: usize) -> Self {
265        Self::new(cfg)
266            .decode()
267            .batch(batch)
268            .past(past_seq)
269            .lm_head()
270    }
271
272    pub fn prefill(mut self) -> Self {
273        self.mode = GemmaMode::Prefill;
274        self
275    }
276
277    pub fn decode(mut self) -> Self {
278        self.mode = GemmaMode::Decode;
279        self
280    }
281
282    pub fn batch(mut self, batch: usize) -> Self {
283        self.batch = batch;
284        self
285    }
286
287    /// Prefill sequence length (ignored in decode mode).
288    pub fn seq(mut self, seq: usize) -> Self {
289        self.seq = seq;
290        self
291    }
292
293    /// Decode past length (ignored in prefill mode).
294    pub fn past(mut self, past_seq: usize) -> Self {
295        self.past_seq = past_seq;
296        self
297    }
298
299    /// Symbolic sequence dim (`sym::SEQ`) for dynamic prefill specialization.
300    pub fn dynamic_seq(mut self) -> Self {
301        self.dynamic_seq = true;
302        self
303    }
304
305    /// Symbolic past dim (`sym::PAST_SEQ`) for dynamic decode specialization.
306    pub fn dynamic_past(mut self) -> Self {
307        self.dynamic_past = true;
308        self
309    }
310
311    pub fn lm_head(mut self) -> Self {
312        self.with_lm_head = true;
313        self
314    }
315
316    /// Hidden states only — skip LM head (default for prefill unless `.lm_head()`).
317    pub fn hidden_only(mut self) -> Self {
318        self.with_lm_head = false;
319        self.last_logits_only = false;
320        self
321    }
322
323    pub fn last_token_logits(mut self) -> Self {
324        self.with_lm_head = true;
325        self.last_logits_only = true;
326        self
327    }
328
329    pub fn export_kv(mut self) -> Self {
330        self.with_kv_outputs = true;
331        self
332    }
333
334    /// EAGLE3 layer-input tap. For each requested layer index, the
335    /// pre-attention-norm hidden state (`inpL`) is appended as an
336    /// extra graph output. Outputs come **after** the KV-cache
337    /// outputs, in **ascending layer-index order**, regardless of
338    /// the order the caller passes them in.
339    ///
340    /// Decode-only for now; calling this with `prefill()` has no
341    /// effect (the prefill path uses the composed
342    /// `gemma_prefill_layer_composed` recipe, which doesn't yet plumb
343    /// the tap — see PLAN.md).
344    ///
345    /// Layer indices ≥ `num_hidden_layers` are silently dropped at
346    /// build time.
347    pub fn with_aux_hidden_outputs(mut self, ids: impl IntoIterator<Item = usize>) -> Self {
348        let mut v: Vec<usize> = ids.into_iter().collect();
349        v.sort_unstable();
350        v.dedup();
351        self.aux_hidden_layer_ids = v;
352        self
353    }
354
355    pub fn custom_mask(mut self) -> Self {
356        self.use_custom_mask = true;
357        self
358    }
359
360    pub fn profile(mut self, profile: CompileProfile) -> Self {
361        self.profile = Some(profile);
362        self
363    }
364
365    /// Fusion-first prefill profile preset.
366    pub fn profile_prefill(mut self) -> Self {
367        self.profile = Some(CompileProfile::gemma_prefill());
368        self
369    }
370
371    pub fn profile_decode(mut self) -> Self {
372        self.profile = Some(CompileProfile::gemma_decode());
373        self
374    }
375
376    pub fn profile_near(mut self, weights_path: &Path) -> Self {
377        let decode = self.mode == GemmaMode::Decode;
378        self.profile = Some(gemma_profile_near_weights(weights_path, decode));
379        self
380    }
381
382    /// Insert custom stages after embedding, before the layer stack.
383    pub fn before_layers(mut self, stages: impl IntoIterator<Item = FlowStage>) -> Self {
384        self.before_layers.extend(stages);
385        self
386    }
387
388    /// Insert custom stages after the layer stack, before final norm / LM head.
389    pub fn after_layers(mut self, stages: impl IntoIterator<Item = FlowStage>) -> Self {
390        self.after_layers.extend(stages);
391        self
392    }
393
394    /// Override per-layer construction (prefill or decode depending on mode).
395    ///
396    /// Call [`GemmaLayerCtx::default_stage`] to keep stock blocks for unmodified layers.
397    pub fn layer<F>(mut self, f: F) -> Self
398    where
399        F: Fn(GemmaLayerCtx<'_>) -> FlowStage + Send + Sync + 'static,
400    {
401        self.layer_fn = Some(Arc::new(f));
402        self
403    }
404
405    /// Patch the assembled [`ModelFlow`] before build — full flexibility escape hatch.
406    pub fn patch_flow<F>(mut self, f: F) -> Self
407    where
408        F: Fn(ModelFlow) -> ModelFlow + Send + Sync + 'static,
409    {
410        self.flow_patch = Some(Arc::new(f));
411        self
412    }
413
414    pub fn build(self, weights: &mut dyn WeightLoader) -> Result<BuiltModel> {
415        match self.mode {
416            GemmaMode::Prefill => self.build_prefill(weights),
417            GemmaMode::Decode => self.build_decode(weights),
418        }
419    }
420
421    fn build_prefill(self, weights: &mut dyn WeightLoader) -> Result<BuiltModel> {
422        if self.dynamic_seq && self.batch != 1 {
423            anyhow::bail!("gemma: dynamic_seq prefill requires batch=1");
424        }
425
426        let cfg = self.cfg;
427        let profile = self.profile.unwrap_or_else(CompileProfile::gemma_prefill);
428        let f = DType::F32;
429        let h = cfg.hidden_size;
430        let eps = cfg.rms_norm_eps as f32;
431        let layer_style = cfg.layer_style();
432
433        let hidden_shape = prefill_hidden_shape(self.batch, self.seq, h, self.dynamic_seq, f);
434        let input_shape = prefill_input_shape(self.batch, self.seq, self.dynamic_seq);
435
436        let rope_factors = weights.take("rope_freqs.weight").ok().map(|(data, _)| data);
437        let inv_freq = resolve_inv_freq(cfg, rope_factors.as_deref());
438        let (cos_data, sin_data) = build_rope_tables(&inv_freq, cfg.max_position_embeddings);
439
440        // When Gemma 4 ships split rope_parameters with a distinct
441        // full-attention theta and/or partial_rotary_factor, build a
442        // second cos/sin table under the "global" slot. The per-layer
443        // closure below opts full-attention layers into it via
444        // SelfAttnPrefillSpec::with_rope_table("global").
445        let global_rope =
446            secondary_rope_tables(cfg, cfg.max_position_embeddings, rope_factors.as_deref());
447
448        let kv_sink = SideOutputs::new();
449
450        let mut flow = ModelFlow::new("gemma").with_profile(profile);
451        if self.prefill_hidden {
452            flow = flow.input("prefill_hidden", hidden_shape.clone());
453        } else {
454            flow = flow.input("input_ids", input_shape);
455        }
456
457        if self.dynamic_seq && self.with_lm_head && self.last_logits_only {
458            flow = flow.input("last_token_idx", Shape::new(&[self.batch], DType::F32));
459        }
460
461        if self.media_attn_bias {
462            let nh = cfg.num_attention_heads;
463            if self.dynamic_seq {
464                flow = flow.input(
465                    "attn_bias",
466                    Shape::from_dims(
467                        &[
468                            rlx_ir::shape::Dim::Static(self.batch),
469                            rlx_ir::shape::Dim::Static(nh),
470                            rlx_ir::shape::Dim::Dynamic(rlx_ir::sym::SEQ),
471                            rlx_ir::shape::Dim::Dynamic(rlx_ir::sym::SEQ),
472                        ],
473                        f,
474                    ),
475                );
476            } else {
477                flow = flow.input(
478                    "attn_bias",
479                    Shape::new(&[self.batch, nh, self.seq, self.seq], f),
480                );
481            }
482        }
483
484        flow = flow
485            .rope_tables(RopeTablesStage::param(
486                cfg.max_position_embeddings,
487                inv_freq.len(),
488                cos_data,
489                sin_data,
490            ))
491            .zero_beta_named("gemma.zero_beta.hidden", h);
492
493        if self.prefill_hidden {
494            flow = flow.plugin_named("gemma.prefill_hidden_bind", move |emit, _| {
495                let hidden = emit
496                    .flow_input("prefill_hidden")
497                    .map_err(|e| anyhow::anyhow!("prefill_hidden input: {e}"))?;
498                // Tied LM head still needs the embedding table in params.
499                let _ = emit.load_param("model.embed_tokens.weight", false)?;
500                Ok(Some(hidden))
501            });
502        } else {
503            flow = flow
504                .token_embed()
505                .raw_stage(FlowStage::EmbedScale(EmbedScaleStage::new(h)));
506        }
507
508        flow = flow.raw_stages(self.before_layers.iter().cloned());
509
510        if let Some(g) = &global_rope {
511            flow = flow.raw_stage(FlowStage::RopeTables(RopeTablesStage::param_named(
512                "global",
513                cfg.max_position_embeddings,
514                g.half_dim,
515                g.cos.clone(),
516                g.sin.clone(),
517            )));
518        }
519
520        let layer_fn = self.layer_fn.clone();
521        let export = self.with_kv_outputs;
522        let media_bias = self.media_attn_bias;
523        let num_heads = cfg.num_attention_heads;
524        let num_layers = cfg.active_num_layers();
525        let layer_attn: Vec<_> = (0..num_layers).map(|i| cfg.layer_attn_options(i)).collect();
526        // PLAN.md M2 — Gemma 4 MoE (`gemma4-26b-a4b`) routes the FFN
527        // through `MoeFfnStage` via the upstream
528        // `gemma_moe_prefill_layer_composed` helper. Dense Gemma
529        // (`is_moe() == false`) keeps the existing default stage.
530        let is_moe = cfg.is_moe();
531        let moe_num_experts = cfg.num_experts;
532        let moe_top_k = cfg.num_experts_used;
533        let moe_n_embd = cfg.hidden_size;
534        let moe_n_ff = cfg.expert_ffn_dim();
535        // Gemma 4 12B varies (head_dim, num_kv_heads, n_rot) across
536        // layers — sliding layers stay at the base shape, global
537        // (full-attention) layers may override. For Gemma <=3 every
538        // accessor returns the uniform value so the closure is a
539        // no-op shape-wise.
540        let per_layer: Vec<PerLayerAttn> = (0..num_layers)
541            .map(|i| PerLayerAttn {
542                head_dim: cfg.layer_head_dim(i),
543                num_kv_heads: cfg.layer_num_kv_heads(i),
544                n_rot: cfg.layer_n_rot(i),
545                rope_table: if cfg.is_full_attention_layer(i) && global_rope.is_some() {
546                    Some("global".to_string())
547                } else {
548                    None
549                },
550                k_eq_v: cfg.attention_k_eq_v,
551            })
552            .collect();
553        flow = flow.repeat_layers(num_layers, {
554            let style = layer_style;
555            let sink = kv_sink.clone();
556            move |i| {
557                let (mask, score_scale, softcap) = layer_attn[i];
558                let pl = &per_layer[i];
559                let lh = pl.head_dim;
560                let mut attn = gemma_attn_spec(
561                    i,
562                    num_heads,
563                    pl.head_dim,
564                    pl.num_kv_heads,
565                    pl.n_rot,
566                    mask,
567                    score_scale,
568                    softcap,
569                );
570                if let Some(name) = pl.rope_table.as_ref() {
571                    attn = attn.with_rope_table(name);
572                }
573                if pl.k_eq_v {
574                    attn = attn.with_k_eq_v();
575                }
576                if let Some(ref f) = layer_fn {
577                    return f(GemmaLayerCtx::Prefill {
578                        index: i,
579                        style,
580                        attn: attn.clone(),
581                        kv_sink: &sink,
582                        export_kv: export,
583                        head_dim: lh,
584                        eps,
585                    });
586                }
587                if media_bias {
588                    return crate::multimodal_flow::multimodal_layer_override(
589                        GemmaLayerCtx::Prefill {
590                            index: i,
591                            style,
592                            attn,
593                            kv_sink: &sink,
594                            export_kv: export,
595                            head_dim: lh,
596                            eps,
597                        },
598                        true,
599                    );
600                }
601                if is_moe {
602                    let prefix = format!("model.layers.{i}");
603                    let moe = rlx_flow::blocks::MoeFfnStage::hf(
604                        prefix,
605                        moe_num_experts,
606                        moe_top_k,
607                        moe_n_embd,
608                        moe_n_ff,
609                    );
610                    let kv = if export { Some(sink.inner()) } else { None };
611                    return rlx_flow::blocks::gemma_moe_prefill_layer_composed(
612                        i, style, attn, eps, kv, moe,
613                    );
614                }
615                GemmaLayerCtx::Prefill {
616                    index: i,
617                    style,
618                    attn,
619                    kv_sink: &sink,
620                    export_kv: export,
621                    head_dim: lh,
622                    eps,
623                }
624                .default_stage()
625            }
626        });
627
628        flow = flow.raw_stages(self.after_layers.iter().cloned());
629
630        if self.with_lm_head && self.last_logits_only {
631            flow = if self.dynamic_seq {
632                flow.gather_last_token_dynamic(self.batch)
633            } else {
634                flow.gather_last_token_at(self.batch, self.seq)
635            };
636        }
637
638        flow = flow.raw_stage(FlowStage::GemmaRmsNorm(GemmaRmsNormStage::hf_layer(
639            "model.norm",
640            eps,
641        )));
642
643        if let Some(patch) = self.flow_patch {
644            flow = patch(flow);
645        }
646
647        let mut built = if self.with_lm_head {
648            let lm = if cfg.tie_word_embeddings {
649                FlowStage::LmHead(LmHeadStage::tied(cfg.vocab_size, h))
650            } else {
651                FlowStage::LmHead(LmHeadStage::separate("lm_head.weight", cfg.vocab_size, h))
652            };
653            flow = flow.raw_stage(lm);
654            if let Some(cap) = cfg.final_logit_softcapping {
655                flow = flow.raw_stage(FlowStage::LogitSoftcap(LogitSoftcapStage::new(cap)));
656            }
657            flow.output("logits")
658                .build(&mut WeightLoaderSource(weights))?
659        } else {
660            flow.output("hidden")
661                .build(&mut WeightLoaderSource(weights))?
662        };
663
664        if self.with_kv_outputs {
665            built = built.with_extra_hir_outputs(kv_sink.drain());
666        }
667        Ok(built)
668    }
669
670    fn build_decode(self, weights: &mut dyn WeightLoader) -> Result<BuiltModel> {
671        let cfg = self.cfg;
672        let profile = self.profile.unwrap_or_else(CompileProfile::gemma_decode);
673        let f = DType::F32;
674        let h = cfg.hidden_size;
675        let eps = cfg.rms_norm_eps as f32;
676        let dh = cfg.head_dim();
677        let half = dh / 2;
678
679        let hidden_shape = Shape::new(&[self.batch, 1, h], f);
680
681        let decode_style = cfg.layer_style();
682        let decode_score_scale = cfg.attn_score_scale();
683        let decode_softcap = cfg.attn_logit_softcapping;
684        let decode_arch = cfg.arch;
685        let decode_sliding = cfg.sliding_window;
686
687        let kv_out = SideOutputs::new();
688        // EAGLE3 aux tap. One SideOutputs sink shared across all tapped
689        // layers. Layer-construction order is ascending, which combined
690        // with `aux_hidden_layer_ids` being sorted gives ascending push
691        // order — so the drained vec is naturally indexed by `(idx of
692        // layer_id in aux_hidden_layer_ids)`.
693        let aux_in = SideOutputs::new();
694        let aux_layer_ids: Vec<usize> = self
695            .aux_hidden_layer_ids
696            .iter()
697            .copied()
698            .filter(|i| *i < cfg.num_hidden_layers)
699            .collect();
700        let aux_in_active = !aux_layer_ids.is_empty();
701        if aux_in_active && cfg.is_moe() {
702            anyhow::bail!(
703                "gemma: with_aux_hidden_outputs is not yet supported on MoE configs \
704                 (`is_moe()=true`). The MoE decode layer goes through \
705                 `gemma_moe_decode_layer_composed`, which does not yet plumb the tap. \
706                 Validate EAGLE3 against the dense Gemma 4 31B target."
707            );
708        }
709
710        let rope_factors = weights.take("rope_freqs.weight").ok().map(|(data, _)| data);
711        let inv_freq = resolve_inv_freq(cfg, rope_factors.as_deref());
712        let (rope_cos, rope_sin) = if self.dynamic_past {
713            (Vec::new(), Vec::new())
714        } else {
715            crate::rope::rope_slice(&inv_freq, self.past_seq)
716        };
717
718        // Static-past mode bakes the per-step cos/sin row as a const.
719        // Dynamic-past mode promotes both default and (Gemma 4) global
720        // rope rows to graph inputs so the runner can supply them at
721        // step-time.
722        let global_rope_row = if !self.dynamic_past {
723            secondary_rope_row(cfg, self.past_seq, rope_factors.as_deref())
724        } else {
725            None
726        };
727        let global_params = needs_secondary_rope_params(cfg);
728
729        let mut flow = ModelFlow::new("gemma_decode")
730            .with_profile(profile)
731            .input("input_ids", Shape::new(&[self.batch, 1], DType::F32));
732
733        if self.dynamic_past {
734            flow = flow
735                .input("rope_cos", Shape::new(&[1, half], f))
736                .input("rope_sin", Shape::new(&[1, half], f));
737            if let Some(gp) = global_params {
738                let half_global =
739                    crate::rope::resolve_global_inv_freq(cfg, rope_factors.as_deref())
740                        .map(|v| v.len())
741                        .unwrap_or_else(|| crate::rope::default_inv_freq(gp.theta, gp.n_rot).len());
742                flow = flow
743                    .input("rope_cos_global", Shape::new(&[1, half_global], f))
744                    .input("rope_sin_global", Shape::new(&[1, half_global], f))
745                    .raw_stage(FlowStage::Custom(rlx_flow::blocks::CustomStage::named(
746                        "gemma.bind_global_decode_rope",
747                        |emit, val| {
748                            // Find the freshly declared inputs in the
749                            // HIR and publish them under the "global"
750                            // slot so per-layer dispatch resolves
751                            // state.named["global_cos"]/_sin.
752                            let cos = find_hir_input(emit.hir(), "rope_cos_global")?;
753                            let sin = find_hir_input(emit.hir(), "rope_sin_global")?;
754                            emit.set_named("global_cos", cos);
755                            emit.set_named("global_sin", sin);
756                            Ok(val)
757                        },
758                    )));
759            }
760        }
761
762        if self.use_custom_mask {
763            flow = flow.input("mask", Shape::new(&[self.batch, self.past_seq + 1], f));
764        }
765
766        // Per-layer past-K/V shapes — sliding layers ship the base
767        // num_kv_heads * head_dim, full-attention layers may ship a
768        // smaller (Gemma 4 12B: 1 * 512 = 512 instead of 8 * 256 =
769        // 2048) cache slot.
770        for layer_idx in 0..cfg.num_hidden_layers {
771            let layer_kv_dim = cfg.layer_num_kv_heads(layer_idx) * cfg.layer_head_dim(layer_idx);
772            let shape = if self.dynamic_past {
773                Shape::from_dims(
774                    &[
775                        Dim::Static(self.batch),
776                        Dim::Dynamic(sym::PAST_SEQ),
777                        Dim::Static(layer_kv_dim),
778                    ],
779                    f,
780                )
781            } else {
782                Shape::new(&[self.batch, self.past_seq, layer_kv_dim], f)
783            };
784            flow = flow
785                .input(format!("past_k_{layer_idx}"), shape.clone())
786                .input(format!("past_v_{layer_idx}"), shape);
787        }
788
789        if !self.dynamic_past {
790            flow = flow.raw_stage(FlowStage::DecodeRopeParams(DecodeRopeParamsStage::new(
791                rope_cos, rope_sin, half,
792            )));
793            if let Some(g) = &global_rope_row {
794                flow = flow.raw_stage(FlowStage::DecodeRopeParams(DecodeRopeParamsStage::named(
795                    "global",
796                    g.cos.clone(),
797                    g.sin.clone(),
798                    g.half_dim,
799                )));
800            }
801        }
802
803        flow = flow
804            .bind_decode_inputs(cfg.num_hidden_layers, self.use_custom_mask, true)
805            .zero_beta_named("gemma.zero_beta.hidden", h)
806            .token_embed()
807            .raw_stage(FlowStage::EmbedScale(EmbedScaleStage::new(h)))
808            .raw_stages(self.before_layers.iter().cloned());
809
810        let layer_fn = self.layer_fn.clone();
811        let use_custom_mask = self.use_custom_mask;
812        let num_heads = cfg.num_attention_heads;
813        let num_layers = cfg.active_num_layers();
814        // Per-layer (head_dim, kv_heads, n_rot) — uniform on Gemma <=3,
815        // diverges on Gemma 4 12B's full-attention layers.
816        let secondary_rope_active = global_rope_row.is_some();
817        let per_layer_decode: Vec<PerLayerAttn> = (0..num_layers)
818            .map(|i| PerLayerAttn {
819                head_dim: cfg.layer_head_dim(i),
820                num_kv_heads: cfg.layer_num_kv_heads(i),
821                n_rot: cfg.layer_n_rot(i),
822                rope_table: if cfg.is_full_attention_layer(i) && secondary_rope_active {
823                    Some("global".to_string())
824                } else {
825                    None
826                },
827                k_eq_v: cfg.attention_k_eq_v,
828            })
829            .collect();
830        // PLAN.md M2 — Gemma 4 MoE (`gemma4-26b-a4b`) decode-side dispatch.
831        let is_moe = cfg.is_moe();
832        let moe_num_experts = cfg.num_experts;
833        let moe_top_k = cfg.num_experts_used;
834        let moe_n_embd = cfg.hidden_size;
835        let moe_n_ff = cfg.expert_ffn_dim();
836        flow = flow.repeat_layers(num_layers, {
837            let sink = kv_out.clone();
838            let aux_sink = aux_in.clone();
839            let aux_ids = aux_layer_ids.clone();
840            let hidden_shape = hidden_shape.clone();
841            move |i| {
842                let aux_for_layer: Option<&SideOutputs> = if aux_ids.binary_search(&i).is_ok() {
843                    Some(&aux_sink)
844                } else {
845                    None
846                };
847                let mask = if use_custom_mask {
848                    rlx_ir::op::MaskKind::Causal
849                } else {
850                    match (decode_arch, decode_sliding) {
851                        (GemmaArch::Gemma2, Some(w)) => rlx_flow::blocks::gemma2_layer_mask(i, w),
852                        // PLAN.md M2 — Gemma 3 / 4 use the strided
853                        // `sliding_window_pattern` (5 sliding + 1
854                        // full for stride 6).
855                        (GemmaArch::Gemma3 | GemmaArch::Gemma4, Some(w)) => {
856                            rlx_flow::blocks::gemma_strided_layer_mask(
857                                i,
858                                w,
859                                decode_arch.sliding_window_stride(),
860                            )
861                        }
862                        _ => rlx_ir::op::MaskKind::Causal,
863                    }
864                };
865                let pl = &per_layer_decode[i];
866                let kv_group_size = num_heads / pl.num_kv_heads;
867                let spec = GemmaDecodeLayerSpec {
868                    style: decode_style,
869                    num_heads,
870                    head_dim: pl.head_dim,
871                    num_kv_heads: pl.num_kv_heads,
872                    kv_group_size,
873                    n_rot: pl.n_rot,
874                    rope_table: pl.rope_table.clone(),
875                    k_eq_v: pl.k_eq_v,
876                    eps,
877                    use_custom_mask,
878                    hidden_shape: hidden_shape.clone(),
879                    mask,
880                    score_scale: decode_score_scale,
881                    attn_logit_softcap: decode_softcap,
882                };
883                if let Some(ref f) = layer_fn {
884                    return f(GemmaLayerCtx::Decode {
885                        index: i,
886                        spec: spec.clone(),
887                        kv_out: &sink,
888                        aux_in: aux_for_layer,
889                    });
890                }
891                if is_moe {
892                    let prefix = format!("model.layers.{i}");
893                    let moe = rlx_flow::blocks::MoeFfnStage::hf(
894                        prefix,
895                        moe_num_experts,
896                        moe_top_k,
897                        moe_n_embd,
898                        moe_n_ff,
899                    );
900                    return rlx_flow::blocks::gemma_moe_decode_layer_composed(
901                        i,
902                        spec,
903                        sink.inner(),
904                        moe,
905                    );
906                }
907                GemmaLayerCtx::Decode {
908                    index: i,
909                    spec,
910                    kv_out: &sink,
911                    aux_in: aux_for_layer,
912                }
913                .default_stage()
914            }
915        });
916
917        flow = flow.raw_stages(self.after_layers.iter().cloned());
918
919        if let Some(patch) = self.flow_patch {
920            flow = patch(flow);
921        }
922
923        let mut flow = flow.raw_stage(FlowStage::GemmaRmsNorm(GemmaRmsNormStage::hf_layer(
924            "model.norm",
925            eps,
926        )));
927        let lm = if cfg.tie_word_embeddings {
928            FlowStage::LmHead(LmHeadStage::tied(cfg.vocab_size, h))
929        } else {
930            FlowStage::LmHead(LmHeadStage::separate("lm_head.weight", cfg.vocab_size, h))
931        };
932        flow = flow.raw_stage(lm);
933        if let Some(cap) = cfg.final_logit_softcapping {
934            flow = flow.raw_stage(FlowStage::LogitSoftcap(LogitSoftcapStage::new(cap)));
935        }
936        // Build must run first — stages only push their KV / aux
937        // HirNodeIds into the shared sinks during stage `emit()`,
938        // which fires inside `flow.build()`.
939        let built = flow
940            .output("logits")
941            .build(&mut WeightLoaderSource(weights))?;
942        let mut extra_outputs = kv_out.drain();
943        if aux_in_active {
944            let aux = aux_in.drain();
945            debug_assert_eq!(
946                aux.len(),
947                aux_layer_ids.len(),
948                "aux tap pushed {} hidden states but {} layer ids were requested",
949                aux.len(),
950                aux_layer_ids.len()
951            );
952            extra_outputs.extend(aux);
953        }
954        let built = built.with_extra_hir_outputs(extra_outputs);
955
956        Ok(built)
957    }
958}
959
960fn prefill_hidden_shape(
961    batch: usize,
962    seq: usize,
963    hidden: usize,
964    dynamic: bool,
965    dtype: DType,
966) -> Shape {
967    if dynamic {
968        Shape::from_dims(
969            &[
970                Dim::Static(batch),
971                Dim::Dynamic(sym::SEQ),
972                Dim::Static(hidden),
973            ],
974            dtype,
975        )
976    } else {
977        Shape::new(&[batch, seq, hidden], dtype)
978    }
979}
980
981fn prefill_input_shape(batch: usize, seq: usize, dynamic: bool) -> Shape {
982    if dynamic {
983        Shape::from_dims(&[Dim::Static(batch), Dim::Dynamic(sym::SEQ)], DType::F32)
984    } else {
985        Shape::new(&[batch, seq], DType::F32)
986    }
987}
988
989/// Per-layer attention dimensions cached at flow-build time. Uniform
990/// across layers for Gemma <=3; diverges on Gemma 4 unified where
991/// full-attention layers may carry different (head_dim, kv_heads,
992/// n_rot) and a secondary RoPE table.
993#[derive(Debug, Clone)]
994struct PerLayerAttn {
995    head_dim: usize,
996    num_kv_heads: usize,
997    n_rot: usize,
998    rope_table: Option<String>,
999    k_eq_v: bool,
1000}
1001
1002#[derive(Debug, Clone)]
1003struct GlobalRopeTables {
1004    cos: Vec<f32>,
1005    sin: Vec<f32>,
1006    half_dim: usize,
1007}
1008
1009/// Build the Gemma 4 "global" (full-attention) RoPE table when the
1010/// unified config carries a distinct rope_theta or
1011/// partial_rotary_factor for full-attention layers. Returns `None`
1012/// for Gemma <=3 and for Gemma 4 configs that omit the split.
1013fn secondary_rope_tables(
1014    cfg: &GemmaConfig,
1015    max_pos: usize,
1016    factors: Option<&[f32]>,
1017) -> Option<GlobalRopeTables> {
1018    let inv = crate::rope::resolve_global_inv_freq(cfg, factors)?;
1019    let (cos, sin) = crate::rope::build_rope_tables(&inv, max_pos);
1020    Some(GlobalRopeTables {
1021        cos,
1022        sin,
1023        half_dim: inv.len(),
1024    })
1025}
1026
1027/// One-position decode row for the global RoPE.
1028fn secondary_rope_row(
1029    cfg: &GemmaConfig,
1030    pos: usize,
1031    factors: Option<&[f32]>,
1032) -> Option<GlobalRopeTables> {
1033    let inv = crate::rope::resolve_global_inv_freq(cfg, factors)?;
1034    let (cos, sin) = crate::rope::rope_slice(&inv, pos);
1035    Some(GlobalRopeTables {
1036        cos,
1037        sin,
1038        half_dim: inv.len(),
1039    })
1040}
1041
1042fn needs_secondary_rope_params(cfg: &GemmaConfig) -> Option<GlobalRopeParams> {
1043    crate::rope::global_rope_params(cfg).map(|(theta, n_rot)| GlobalRopeParams { theta, n_rot })
1044}
1045
1046#[derive(Debug, Clone, Copy)]
1047struct GlobalRopeParams {
1048    theta: f64,
1049    n_rot: usize,
1050}
1051
1052fn find_hir_input(hir: &HirModule, name: &str) -> anyhow::Result<rlx_ir::HirNodeId> {
1053    use rlx_ir::hir::HirOp;
1054    for node in hir.nodes() {
1055        if let HirOp::Input { name: n } = &node.op {
1056            if n == name {
1057                return Ok(node.id);
1058            }
1059        }
1060    }
1061    Err(anyhow::anyhow!("gemma decode flow missing input: {name}"))
1062}
1063
1064// ── Legacy opt structs + thin wrappers (backward compatible) ─────────
1065
1066impl<'a> GemmaFlow<'a> {
1067    fn from_prefill_opts(cfg: &'a GemmaConfig, o: &GemmaPrefillOpts) -> Self {
1068        let mut f = GemmaFlow::new(cfg).prefill().batch(o.batch).seq(o.seq);
1069        if o.dynamic_seq {
1070            f = f.dynamic_seq();
1071        }
1072        if o.prefill_hidden {
1073            f = f.prefill_from_hidden();
1074        }
1075        if o.media_attn_bias {
1076            f = f.prefill_media_attn_bias();
1077        }
1078        if o.with_lm_head {
1079            f = f.lm_head();
1080        }
1081        if o.with_kv_outputs {
1082            f = f.export_kv();
1083        }
1084        if o.last_logits_only {
1085            f = f.last_token_logits();
1086        }
1087        if let Some(p) = o.profile.clone() {
1088            f = f.profile(p);
1089        }
1090        f
1091    }
1092
1093    fn from_decode_opts(cfg: &'a GemmaConfig, o: &GemmaDecodeOpts) -> Self {
1094        let mut f = GemmaFlow::new(cfg)
1095            .decode()
1096            .batch(o.batch)
1097            .past(o.past_seq)
1098            .lm_head();
1099        if o.dynamic_past {
1100            f = f.dynamic_past();
1101        }
1102        if o.use_custom_mask {
1103            f = f.custom_mask();
1104        }
1105        if let Some(p) = o.profile.clone() {
1106            f = f.profile(p);
1107        }
1108        if !o.aux_hidden_layer_ids.is_empty() {
1109            f = f.with_aux_hidden_outputs(o.aux_hidden_layer_ids.iter().copied());
1110        }
1111        f
1112    }
1113}
1114
1115/// Options for the tier-0 Gemma prefill assembly line.
1116#[derive(Debug, Clone)]
1117pub struct GemmaPrefillOpts {
1118    pub batch: usize,
1119    pub seq: usize,
1120    pub dynamic_seq: bool,
1121    pub prefill_hidden: bool,
1122    pub media_attn_bias: bool,
1123    pub with_lm_head: bool,
1124    pub with_kv_outputs: bool,
1125    pub last_logits_only: bool,
1126    pub profile: Option<CompileProfile>,
1127}
1128
1129impl GemmaPrefillOpts {
1130    pub fn static_prefill(batch: usize, seq: usize) -> Self {
1131        Self {
1132            batch,
1133            seq,
1134            dynamic_seq: false,
1135            prefill_hidden: false,
1136            media_attn_bias: false,
1137            with_lm_head: false,
1138            with_kv_outputs: false,
1139            last_logits_only: false,
1140            profile: None,
1141        }
1142    }
1143}
1144
1145/// Options for tier-0 Gemma decode (KV-cache) assembly line.
1146#[derive(Debug, Clone)]
1147pub struct GemmaDecodeOpts {
1148    pub batch: usize,
1149    pub past_seq: usize,
1150    pub dynamic_past: bool,
1151    pub use_custom_mask: bool,
1152    pub profile: Option<CompileProfile>,
1153    /// EAGLE3 layer-input tap: target layer indices whose
1154    /// pre-attention-norm hidden states should be emitted as extra
1155    /// graph outputs after the KV outputs. Empty ⇒ disabled.
1156    /// Mirrors [`GemmaFlow::with_aux_hidden_outputs`].
1157    #[doc(alias = "eagle3")]
1158    pub aux_hidden_layer_ids: Vec<usize>,
1159}
1160
1161impl GemmaDecodeOpts {
1162    /// Default decode opts (no aux tap). Mirrors what was the old
1163    /// in-struct default before the EAGLE3 tap landed.
1164    pub fn new(batch: usize, past_seq: usize) -> Self {
1165        Self {
1166            batch,
1167            past_seq,
1168            dynamic_past: false,
1169            use_custom_mask: false,
1170            profile: None,
1171            aux_hidden_layer_ids: Vec::new(),
1172        }
1173    }
1174
1175    /// Enable EAGLE3 aux hidden-state outputs on this decode build.
1176    pub fn with_aux_hidden_layer_ids(mut self, ids: impl IntoIterator<Item = usize>) -> Self {
1177        self.aux_hidden_layer_ids = ids.into_iter().collect();
1178        self
1179    }
1180}
1181
1182pub fn build_gemma_prefill_flow(
1183    cfg: &GemmaConfig,
1184    weights: &mut dyn WeightLoader,
1185    opts: &GemmaPrefillOpts,
1186) -> Result<(HirModule, HashMap<String, Vec<f32>>)> {
1187    build_gemma_prefill_built(cfg, weights, opts)?.into_parts()
1188}
1189
1190pub fn build_gemma_prefill_built(
1191    cfg: &GemmaConfig,
1192    weights: &mut dyn WeightLoader,
1193    opts: &GemmaPrefillOpts,
1194) -> Result<BuiltModel> {
1195    GemmaFlow::from_prefill_opts(cfg, opts).build(weights)
1196}
1197
1198pub fn build_gemma_decode_flow(
1199    cfg: &GemmaConfig,
1200    weights: &mut dyn WeightLoader,
1201    opts: &GemmaDecodeOpts,
1202) -> Result<(HirModule, HashMap<String, Vec<f32>>)> {
1203    build_gemma_decode_built(cfg, weights, opts)?.into_parts()
1204}
1205
1206pub fn build_gemma_decode_graph(
1207    cfg: &GemmaConfig,
1208    weights: &mut dyn WeightLoader,
1209    opts: &GemmaDecodeOpts,
1210) -> Result<(Graph, HashMap<String, Vec<f32>>)> {
1211    rlx_core::flow_util::graph_from_built(build_gemma_decode_built(cfg, weights, opts)?)
1212}
1213
1214pub fn build_gemma_decode_built(
1215    cfg: &GemmaConfig,
1216    weights: &mut dyn WeightLoader,
1217    opts: &GemmaDecodeOpts,
1218) -> Result<BuiltModel> {
1219    GemmaFlow::from_decode_opts(cfg, opts).build(weights)
1220}
1221
1222#[cfg(test)]
1223mod gemma4_tests {
1224    use super::*;
1225    use crate::config::{
1226        GemmaArch, GemmaLayerType, GemmaRopeKind, GemmaRopeMap, GemmaRopeParameters,
1227    };
1228
1229    fn gemma4_12b_like() -> GemmaConfig {
1230        let mut cfg = GemmaConfig::tiny_test();
1231        cfg.arch = GemmaArch::Gemma4;
1232        cfg.hidden_size = 3840;
1233        cfg.intermediate_size = 15_360;
1234        cfg.num_hidden_layers = 12; // small for tests
1235        cfg.num_attention_heads = 16;
1236        cfg.num_key_value_heads = 8;
1237        cfg.head_dim = Some(256);
1238        cfg.global_head_dim = Some(512);
1239        cfg.num_global_key_value_heads = Some(1);
1240        cfg.attention_k_eq_v = true;
1241        cfg.sliding_window = Some(1024);
1242        cfg.final_logit_softcapping = Some(30.0);
1243        cfg.tie_word_embeddings = true;
1244        cfg.max_position_embeddings = 4096;
1245        cfg.rope_theta = 10_000.0;
1246        // Stride-6 pattern: every 6th layer (1-indexed) is full.
1247        cfg.layer_types = (0..cfg.num_hidden_layers)
1248            .map(|i| {
1249                if (i + 1) % 6 == 0 {
1250                    GemmaLayerType::FullAttention
1251                } else {
1252                    GemmaLayerType::SlidingAttention
1253                }
1254            })
1255            .collect();
1256        cfg.rope_parameters = GemmaRopeMap {
1257            sliding_attention: Some(GemmaRopeParameters {
1258                rope_theta: Some(10_000.0),
1259                rope_type: Some(GemmaRopeKind::Default),
1260                partial_rotary_factor: None,
1261            }),
1262            full_attention: Some(GemmaRopeParameters {
1263                rope_theta: Some(1_000_000.0),
1264                rope_type: Some(GemmaRopeKind::Proportional),
1265                partial_rotary_factor: Some(0.25),
1266            }),
1267        };
1268        cfg
1269    }
1270
1271    #[test]
1272    fn secondary_rope_emits_distinct_table_for_full_attention() {
1273        let cfg = gemma4_12b_like();
1274        let tables = secondary_rope_tables(&cfg, cfg.max_position_embeddings, None)
1275            .expect("Gemma 4 split rope_parameters should produce a secondary table");
1276        // The cos/sin table row stride is head_dim/2 = 256 (the RoPE kernel's
1277        // `tab_half`); only the leading n_rot/2 = 64 entries are rotated freqs,
1278        // the rest are zeroed NoPE dims.
1279        assert_eq!(tables.half_dim, 256);
1280        assert_eq!(tables.cos.len(), cfg.max_position_embeddings * 256);
1281        assert_eq!(tables.sin.len(), tables.cos.len());
1282
1283        // pos=0 row is always (1, 0) regardless of theta.
1284        assert!((tables.cos[0] - 1.0).abs() < 1e-6);
1285        assert!(tables.sin[0].abs() < 1e-6);
1286        // The frequency exponent kicks in for dim>=1: at pos=1, dim=5
1287        // the two thetas should produce different cos values.
1288        //
1289        // Proportional partial RoPE: the global inv_freq exponent uses the
1290        // FULL head_dim (512) as denominator even though only 128 dims rotate
1291        // (HF `_compute_proportional_rope_parameters`). So the expected sample
1292        // is from `default_inv_freq(theta, 512)`, NOT the rotary count 128.
1293        let global_inv = crate::rope::default_inv_freq(1_000_000.0, 512);
1294        let sliding_inv = crate::rope::default_inv_freq(10_000.0, 256);
1295        assert!((global_inv[5] - sliding_inv[5]).abs() > 1e-3);
1296        let global_cos_p1_d5 = (1.0 * global_inv[5]).cos();
1297        let global_sample = tables.cos[256 + 5]; // pos=1, dim=5 (stride head_dim/2=256)
1298        assert!((global_sample as f64 - global_cos_p1_d5).abs() < 1e-5);
1299        // The rotated freqs occupy the first 64 cols; col 64+ is zeroed → cos=1.
1300        assert!((tables.cos[256 + 64] - 1.0).abs() < 1e-6);
1301    }
1302
1303    #[test]
1304    fn per_layer_kv_dims_diverge_on_full_attention() {
1305        let cfg = gemma4_12b_like();
1306        // Sliding: 8 heads * 256 = 2048.
1307        assert_eq!(cfg.layer_num_kv_heads(0) * cfg.layer_head_dim(0), 2048);
1308        // Full: 1 head * 512 = 512.
1309        assert_eq!(cfg.layer_num_kv_heads(5) * cfg.layer_head_dim(5), 512);
1310        assert_eq!(cfg.layer_num_kv_heads(11) * cfg.layer_head_dim(11), 512);
1311    }
1312
1313    #[test]
1314    fn no_secondary_table_when_params_match() {
1315        // Gemma 3-shape (uniform rope) — secondary table should not
1316        // be emitted even if arch is Gemma4 (e.g. a tuned variant
1317        // with collapsed rope_parameters).
1318        let mut cfg = gemma4_12b_like();
1319        cfg.rope_parameters.full_attention = cfg.rope_parameters.sliding_attention;
1320        cfg.global_head_dim = None;
1321        cfg.num_global_key_value_heads = None;
1322        assert!(secondary_rope_tables(&cfg, cfg.max_position_embeddings, None).is_none());
1323    }
1324}