Skip to main content

memra_engine/
eagle.rs

1//! EAGLE3.1 greedy-chain speculative decode (research/basics/EAGLE-PLAN.md, N1-N7).
2//!
3//! Greedy spec decode is MATHEMATICALLY EXACT: the accepted+bonus token stream is token-for-token
4//! identical to plain greedy `generate` (decode.rs). EAGLE differs from MTP (spec.rs) ONLY in the
5//! DRAFT step: instead of the trunk-coupled NextN head, EAGLE drafts with a SEPARATE 1-layer model
6//! (own vocab, own RoPE, untied lm_head) fed the trunk's hidden states from 3 aux layers [1,15,28]
7//! fused through an encoder `fc`. The verify / accept-prefix / snapshot / rollback are REUSED
8//! VERBATIM from spec.rs (decode_step_t, the greedy accept walk, cache.snapshot/rollback).
9//!
10//! On-disk draft (`eagle3-qwen35-9b/model.safetensors`, bf16, ground-truthed at impl time):
11//!   fc.weight                            [4096, 12288]  (3*n_embd -> n_embd encoder)
12//!   midlayer.input_layernorm.weight      [4096]         (RMSNorm of the prev-token EMBED)
13//!   midlayer.hidden_norm.weight          [4096]         (RMSNorm of the recurrent hidden g)
14//!   midlayer.self_attn.{q,k,v}_proj      q[4096,8192] k/v[1024,8192]  (in = 2*n_embd!)
15//!   midlayer.self_attn.o_proj            [4096, 4096]
16//!   midlayer.post_attention_layernorm    [4096]
17//!   midlayer.mlp.{gate,up}_proj          [12288,4096]   down [4096,12288]
18//!   norm.weight                          [4096]         (final RMSNorm before lm_head)
19//!   lm_head.weight                       [32000, 4096]  (DRAFT vocab)
20//!   d2t                                  [32000] i64    target_id = draft_id + d2t[draft_id]
21//!   t2d                                  [248320] bool  (unused on the chain-greedy decode path)
22//!
23//! Op-sequence (authoritative: vLLM `llama_eagle3.py` LlamaDecoderLayer layer_idx==0, this ckpt's
24//! flags norm_before_residual=false, norm_before_fc=false, fc_norm=false, norm_output=false):
25//!   ENCODE (once/round): g = fc @ concat(aux[1], aux[15], aux[28])                 -> [n_embd]
26//!   DRAFT step (T=1):
27//!     e   = embed(prev_tok)                          (TARGET embedding; EAGLE3 shares it)
28//!     eN  = RMSNorm(e,  input_layernorm)
29//!     res = g                                         (_norm_after_residual: residual is PRE-norm g)
30//!     gN  = RMSNorm(g,  hidden_norm)
31//!     cat = [eN ; gN]                                 -> [2*n_embd]
32//!     attn= o_proj @ SDPA( q,k,v = {q,k,v}_proj @ cat ; partial RoPE 64/256 @ theta 1e7 ; GQA16:4 )
33//!     x1  = attn + res
34//!     z   = RMSNorm(x1, post_attention_layernorm)
35//!     mlp = down @ silu(gate @ z) * (up @ z)
36//!     gsum= mlp + x1                                  (the model's final fused-add residual)
37//!     dl  = lm_head @ RMSNorm(gsum, norm)             -> draft_logits[32000]
38//!     g_next = gsum                                   (EAGLE recurrence: pre-norm residual)
39
40use crate::Engine;
41use crate::cache::{Cache, KvLayer};
42use crate::forward::argmax;
43use crate::hybrid::HybridModel;
44use crate::model::GpuTensor;
45use cudarc::driver::CudaSlice;
46use memra_gguf::dequant;
47use memra_gguf::safetensors::StModel;
48use std::path::Path;
49
50/// The EAGLE3 draft model: encoder `fc` + ONE Llama-style decoder layer + untied lm_head + d2t.
51/// All weights are bf16 -> dequant to f32 GpuTensor::Float (the draft is ~0.8 GB; the matmuls go
52/// through cuBLASLt `linear`). The draft attention is PLAIN Llama (no QK-norm, no output gate),
53/// distinct from the trunk's gated/QK-normed full-attn.
54pub struct Eagle3Draft {
55    pub fc: GpuTensor,              // [3*n_embd, n_embd]  encoder
56    pub input_layernorm: GpuTensor, // [n_embd]  norm of prev-token embedding
57    pub hidden_norm: GpuTensor,     // [n_embd]  norm of recurrent g
58    pub q_proj: GpuTensor,          // [2*n_embd, n_head*head_dim]
59    pub k_proj: GpuTensor,          // [2*n_embd, n_head_kv*head_dim]
60    pub v_proj: GpuTensor,          // [2*n_embd, n_head_kv*head_dim]
61    pub o_proj: GpuTensor,          // [n_head*head_dim, n_embd]
62    pub post_attention_layernorm: GpuTensor,
63    pub gate_proj: GpuTensor,
64    pub up_proj: GpuTensor,
65    pub down_proj: GpuTensor,
66    pub norm: GpuTensor,    // [n_embd]  final RMSNorm before lm_head
67    pub lm_head: GpuTensor, // [n_embd, draft_vocab]
68    pub d2t: Vec<i64>,      // [draft_vocab]  target_id = draft_id + d2t[draft_id]
69
70    // shape / rope params (from the draft config.json, NOT the trunk cfg)
71    pub n_embd: usize,
72    pub n_head: usize,
73    pub n_head_kv: usize,
74    pub head_dim: usize,
75    pub n_ff: usize,
76    pub draft_vocab: usize,
77    pub rope_dim_count: usize, // resolve_rope_dim_count (shared with GGUF/HF readers): 64 of 256
78    pub rope_theta: f32,       // 1e7
79    pub eps: f32,
80    pub aux_layers: Vec<usize>, // [1, 15, 28]
81}
82
83fn validate_eagle_tensor(
84    name: &str,
85    info: &memra_gguf::safetensors::StInfo,
86    expected_ne: &[u64],
87) -> Result<Vec<u64>, String> {
88    let ne = info.ne();
89    if !matches!(info.dtype.as_str(), "BF16" | "F32") {
90        return Err(format!(
91            "EAGLE3 tensor {name} has dtype {}, expected BF16 or F32",
92            info.dtype
93        ));
94    }
95    if ne != expected_ne {
96        return Err(format!(
97            "EAGLE3 tensor {name} has shape {ne:?}, expected {expected_ne:?}"
98        ));
99    }
100    Ok(ne)
101}
102
103fn validate_aux_layers(aux_layers: &[usize]) -> Result<(), String> {
104    if aux_layers.len() != 3 {
105        return Err(format!(
106            "EAGLE3 fc.weight consumes exactly three auxiliary hidden states; config declares {}",
107            aux_layers.len()
108        ));
109    }
110    if aux_layers.windows(2).any(|pair| pair[0] >= pair[1]) {
111        return Err("EAGLE3 auxiliary layer ids must be strictly increasing".into());
112    }
113    Ok(())
114}
115
116fn validate_eagle_attention_geometry(
117    n_head: usize,
118    n_head_kv: usize,
119    head_dim: usize,
120) -> Result<(), String> {
121    if n_head == 0
122        || n_head_kv == 0
123        || head_dim == 0
124        || !n_head.is_multiple_of(n_head_kv)
125        || !head_dim.is_multiple_of(32)
126    {
127        return Err(format!(
128            "EAGLE3 attention geometry requires nonzero n_head divisible by n_head_kv and head_dim divisible by 32; got n_head={n_head}, n_head_kv={n_head_kv}, head_dim={head_dim}"
129        ));
130    }
131    n_head
132        .checked_mul(head_dim)
133        .ok_or("EAGLE3 query-head geometry overflow")?;
134    n_head_kv
135        .checked_mul(head_dim)
136        .ok_or("EAGLE3 key/value-head geometry overflow")?;
137    Ok(())
138}
139
140fn validate_d2t_map(
141    d2t: &[i64],
142    draft_vocab: usize,
143    target_vocab: Option<usize>,
144) -> Result<(), String> {
145    if d2t.len() != draft_vocab {
146        return Err(format!(
147            "EAGLE3 d2t has {} entries, expected draft_vocab_size {draft_vocab}",
148            d2t.len()
149        ));
150    }
151    for (draft_id, delta) in d2t.iter().copied().enumerate() {
152        let target = i64::try_from(draft_id)
153            .ok()
154            .and_then(|id| id.checked_add(delta))
155            .filter(|target| (0..=i64::from(u32::MAX)).contains(target))
156            .filter(|target| {
157                target_vocab.is_none_or(|vocab| usize::try_from(*target).is_ok_and(|id| id < vocab))
158            });
159        if target.is_none() {
160            let limit = target_vocab
161                .map(|vocab| format!(" target vocabulary of {vocab} entries"))
162                .unwrap_or_else(|| " target u32 vocabulary".to_string());
163            return Err(format!(
164                "EAGLE3 d2t[{draft_id}]={delta} maps outside the{limit}"
165            ));
166        }
167    }
168    Ok(())
169}
170
171/// Load a single bf16 (or f32) tensor from the draft safetensors into a GpuTensor::Float.
172/// `name` is the raw HF/EAGLE name in the file (e.g. "fc.weight", "midlayer.self_attn.q_proj.weight").
173fn load_float(
174    e: &Engine,
175    m: &StModel,
176    name: &str,
177    expected_ne: &[u64],
178) -> Result<GpuTensor, Box<dyn std::error::Error>> {
179    let (info, bytes) = m
180        .raw(name)
181        .ok_or_else(|| format!("EAGLE3 draft missing tensor {name}"))?;
182    let ne = validate_eagle_tensor(name, info, expected_ne)?;
183    let n = ne.iter().try_fold(1u64, |total, extent| {
184        total
185            .checked_mul(*extent)
186            .ok_or_else(|| format!("EAGLE3 tensor {name} element count overflow"))
187    })?;
188    let f32v = dequant::dequantize(info.ggml_type()?, bytes, n as usize);
189    Ok(GpuTensor::Float {
190        data: e.htod(&f32v)?,
191        ne,
192    })
193}
194
195impl Eagle3Draft {
196    /// Load the EAGLE3 draft from a checkpoint directory (config.json + model.safetensors) or a
197    /// direct path to the .safetensors. Reads the geometry/rope params from the sibling config.json.
198    /// `aux_layers` is the trunk layer-id list from `eagle_config.eagle_aux_hidden_state_layer_ids`.
199    pub fn load(e: &Engine, path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
200        let dir = if path.is_file() {
201            path.parent().unwrap_or(Path::new("."))
202        } else {
203            path
204        };
205        let cfg = EagleConfig::from_json(&dir.join("config.json"))?;
206        validate_aux_layers(&cfg.aux_layers)?;
207        validate_eagle_attention_geometry(cfg.n_head, cfg.n_head_kv, cfg.head_dim)?;
208        let m = StModel::open(path)?;
209
210        let d2t = read_i64(&m, "d2t")?;
211        validate_d2t_map(&d2t, cfg.draft_vocab, None)?;
212
213        let n = cfg.hidden_size as u64;
214        let two_n = n.checked_mul(2).ok_or("EAGLE3 hidden geometry overflow")?;
215        let three_n = n.checked_mul(3).ok_or("EAGLE3 hidden geometry overflow")?;
216        let ff = cfg.intermediate_size as u64;
217        let q = cfg
218            .n_head
219            .checked_mul(cfg.head_dim)
220            .ok_or("EAGLE3 q projection geometry overflow")? as u64;
221        let kv = cfg
222            .n_head_kv
223            .checked_mul(cfg.head_dim)
224            .ok_or("EAGLE3 kv projection geometry overflow")? as u64;
225        let vocab = cfg.draft_vocab as u64;
226
227        let draft = Eagle3Draft {
228            fc: load_float(e, &m, "fc.weight", &[three_n, n])?,
229            input_layernorm: load_float(e, &m, "midlayer.input_layernorm.weight", &[n])?,
230            hidden_norm: load_float(e, &m, "midlayer.hidden_norm.weight", &[n])?,
231            q_proj: load_float(e, &m, "midlayer.self_attn.q_proj.weight", &[two_n, q])?,
232            k_proj: load_float(e, &m, "midlayer.self_attn.k_proj.weight", &[two_n, kv])?,
233            v_proj: load_float(e, &m, "midlayer.self_attn.v_proj.weight", &[two_n, kv])?,
234            o_proj: load_float(e, &m, "midlayer.self_attn.o_proj.weight", &[q, n])?,
235            post_attention_layernorm: load_float(
236                e,
237                &m,
238                "midlayer.post_attention_layernorm.weight",
239                &[n],
240            )?,
241            gate_proj: load_float(e, &m, "midlayer.mlp.gate_proj.weight", &[n, ff])?,
242            up_proj: load_float(e, &m, "midlayer.mlp.up_proj.weight", &[n, ff])?,
243            down_proj: load_float(e, &m, "midlayer.mlp.down_proj.weight", &[ff, n])?,
244            norm: load_float(e, &m, "norm.weight", &[n])?,
245            lm_head: load_float(e, &m, "lm_head.weight", &[n, vocab])?,
246            d2t,
247            n_embd: cfg.hidden_size,
248            n_head: cfg.n_head,
249            n_head_kv: cfg.n_head_kv,
250            head_dim: cfg.head_dim,
251            n_ff: cfg.intermediate_size,
252            draft_vocab: cfg.draft_vocab,
253            rope_dim_count: cfg.rope_dim_count(),
254            rope_theta: cfg.rope_theta,
255            eps: cfg.rms_eps,
256            aux_layers: cfg.aux_layers,
257        };
258        Ok(draft)
259    }
260
261    /// Map a DRAFT-vocab id to a TARGET-vocab id (d2t is a DELTA: target = draft + d2t[draft]).
262    #[inline]
263    pub fn d2t_map(&self, draft_id: u32) -> u32 {
264        (draft_id as i64 + self.d2t[draft_id as usize]) as u32
265    }
266
267    /// ENCODE (once per round, EAGLE-PLAN N3): g = fc @ concat(aux0, aux1, aux2). `aux` are the 3
268    /// trunk residual hiddens of the just-committed token (decode_step_aux / decode_step_t_aux),
269    /// in ascending-layer order. Returns the recurrent draft hidden `g` [n_embd].
270    pub fn encode(
271        &self,
272        e: &Engine,
273        aux: &[CudaSlice<f32>],
274    ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
275        if aux.len() != self.aux_layers.len() {
276            return Err(format!(
277                "EAGLE3 encode received {} auxiliary states, expected {}",
278                aux.len(),
279                self.aux_layers.len()
280            )
281            .into());
282        }
283        let n = self.n_embd;
284        let mut cat = e.zeros(self.aux_layers.len() * n)?;
285        for (i, a) in aux.iter().enumerate() {
286            e.copy_into(&mut cat, i * n, a, n)?;
287        }
288        e.matmul(&self.fc, &cat, 1) // [3*n_embd] @ fc[3n_embd,n_embd] -> [n_embd]
289    }
290
291    /// One DRAFT-token forward (EAGLE-PLAN N4, T=1). `prev_tok` = the TARGET token id to predict
292    /// from (last committed or previous draft). `g` = the recurrent draft hidden (encode() output
293    /// on round entry, then the previous step's g_next). Returns (draft_logits[draft_vocab] host,
294    /// g_next dev). Mirrors the vLLM op-sequence documented at the top of this file.
295    pub fn draft_token(
296        &self,
297        e: &Engine,
298        target: &HybridModel,
299        prev_tok: u32,
300        g: &CudaSlice<f32>,
301        scratch: &mut Eagle3Scratch,
302        pos: usize,
303    ) -> Result<(Vec<f32>, CudaSlice<f32>), Box<dyn std::error::Error>> {
304        let n = self.n_embd;
305        let eps = self.eps;
306        let pos_d = e.htod_i32(&[pos as i32])?;
307
308        // e = TARGET embedding of prev_tok (EAGLE3 shares the target's token embedding).
309        // eN = input_layernorm(e); gN = hidden_norm(g); residual = PRE-norm g (norm_after_residual).
310        let e_emb = e.htod(&target.embd.gather(n, &[prev_tok]))?;
311        let mut e_norm = e.zeros(n)?;
312        e.rms_norm(
313            &e_emb,
314            self.input_layernorm.float_data(),
315            &mut e_norm,
316            n,
317            1,
318            eps,
319        )?;
320        let res = e.clone_dtod(g)?;
321        let mut g_norm = e.zeros(n)?;
322        e.rms_norm(g, self.hidden_norm.float_data(), &mut g_norm, n, 1, eps)?;
323        // cat = [eN ; gN] -> [2*n_embd]  (vLLM llama_eagle3: torch.cat([embeds, hidden_states])).
324        let mut cat = e.zeros(2 * n)?;
325        e.copy_into(&mut cat, 0, &e_norm, n)?;
326        e.copy_into(&mut cat, n, &g_norm, n)?;
327
328        // attention from the 2*n_embd concat (plain Llama: no QK-norm, no output gate).
329        let attn = self.attn(e, &cat, &pos_d, scratch)?;
330        // x1 = attn + residual(g)
331        let mut x1 = e.zeros(n)?;
332        e.add(&attn, &res, &mut x1, n)?;
333        // z = post_attention_layernorm(x1)
334        let mut z = e.zeros(n)?;
335        e.rms_norm(
336            &x1,
337            self.post_attention_layernorm.float_data(),
338            &mut z,
339            n,
340            1,
341            eps,
342        )?;
343        // mlp = down @ (silu(gate@z) * (up@z))
344        let gate = e.matmul(&self.gate_proj, &z, 1)?;
345        let up = e.matmul(&self.up_proj, &z, 1)?;
346        let mut act = e.zeros(self.n_ff)?;
347        e.silu_mul(&gate, &up, &mut act, self.n_ff)?;
348        let mlp = e.matmul(&self.down_proj, &act, 1)?;
349        // g_next = mlp + x1  (final fused-add residual; this is the aux_output recurrence)
350        let mut g_next = e.zeros(n)?;
351        e.add(&mlp, &x1, &mut g_next, n)?;
352        // dl = lm_head @ norm(g_next)
353        let mut hn = e.zeros(n)?;
354        e.rms_norm(&g_next, self.norm.float_data(), &mut hn, n, 1, eps)?;
355        let logits = e.matmul(&self.lm_head, &hn, 1)?;
356        let host = e.dtoh(&logits)?;
357        Ok((host, g_next))
358    }
359
360    /// Plain Llama attention over the [2*n_embd] concat input, T=1, on the draft's own scratch KV.
361    /// q/k/v project from 2*n_embd; partial RoPE (rope_dim_count of head_dim) at the draft theta;
362    /// GQA broadcast in fa_decode; o_proj back to n_embd. No QK-norm, no output gate.
363    fn attn(
364        &self,
365        e: &Engine,
366        cat: &CudaSlice<f32>,
367        pos_d: &CudaSlice<i32>,
368        scratch: &mut Eagle3Scratch,
369    ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
370        let (nh, nhkv, hd) = (self.n_head, self.n_head_kv, self.head_dim);
371        let scale = 1.0 / (hd as f32).sqrt();
372        let mut q = e.matmul(&self.q_proj, cat, 1)?; // [nh*hd]
373        let mut k = e.matmul(&self.k_proj, cat, 1)?; // [nhkv*hd]
374        let v = e.matmul(&self.v_proj, cat, 1)?; // [nhkv*hd]
375
376        // partial RoPE: rope_dim_count from resolve_rope_dim_count (= 64 of 256), draft theta.
377        e.rope_neox(
378            &mut q,
379            pos_d,
380            hd,
381            self.rope_dim_count,
382            nh,
383            1,
384            self.rope_theta,
385            1.0,
386        )?;
387        e.rope_neox(
388            &mut k,
389            pos_d,
390            hd,
391            self.rope_dim_count,
392            nhkv,
393            1,
394            self.rope_theta,
395            1.0,
396        )?;
397
398        let kv = &mut scratch.kv;
399        e.append_kv_quantized(
400            &k,
401            &v,
402            &mut kv.k,
403            &mut kv.v,
404            kv.len,
405            kv.kv_dim_k,
406            kv.kv_dim_v,
407            kv.k_tok_bytes,
408            kv.v_tok_bytes,
409            false,
410        )?;
411        kv.len += 1;
412        let t_kv = kv.len;
413        let (ktb, vtb) = (kv.k_tok_bytes, kv.v_tok_bytes);
414        let k_view = e.view_u8(&kv.k, t_kv * ktb);
415        let v_view = e.view_u8(&kv.v, t_kv * vtb);
416        let mut attn = e.zeros(nh * hd)?;
417        e.fa_decode(
418            &q, &k_view, &v_view, &mut attn, hd, nh, nhkv, t_kv, scale, ktb, vtb,
419        )?;
420        e.matmul(&self.o_proj, &attn, 1)
421    }
422}
423
424/// Tiny scratch KV for the EAGLE3 draft layer (one full-attn layer). Reset each draft round. Uses
425/// the SAME q8_0-K / q5_1-V quantized layout as the trunk KV (head_dim%32==0 holds: 256).
426pub struct Eagle3Scratch {
427    pub kv: KvLayer,
428}
429impl Eagle3Scratch {
430    pub fn new(
431        e: &Engine,
432        draft: &Eagle3Draft,
433        cap: usize,
434    ) -> Result<Self, Box<dyn std::error::Error>> {
435        let (nhkv, hd) = (draft.n_head_kv, draft.head_dim);
436        assert!(
437            hd % 32 == 0,
438            "KVQUANT requires head_dim%32==0 (EAGLE3 scratch)"
439        );
440        let kv_dim_k = hd * nhkv;
441        let kv_dim_v = hd * nhkv;
442        let (kbb, vbb) = crate::kv_blk_bytes(); // env-selected KV formats (default 34/24)
443        let k_tok_bytes = (kv_dim_k / 32) * kbb;
444        let v_tok_bytes = (kv_dim_v / 32) * vbb;
445        Ok(Eagle3Scratch {
446            kv: KvLayer {
447                k: e.alloc_u8(cap * k_tok_bytes)?,
448                v: e.alloc_u8(cap * v_tok_bytes)?,
449                kv_dim_k,
450                kv_dim_v,
451                k_tok_bytes,
452                v_tok_bytes,
453                len: 0,
454                ring: None,
455                len_d: e.htod_i32(&[0])?,
456                base_d: None,
457            },
458        })
459    }
460    pub fn reset(&mut self) {
461        self.kv.len = 0;
462    }
463}
464
465impl HybridModel {
466    /// Greedy EAGLE3 speculative decode (EAGLE-PLAN N6). Token-identical to `generate(prompt,n)`
467    /// but drafts K tokens with the separate EAGLE3 draft, then verifies them in ONE batched target
468    /// forward. Verify/accept/snapshot/rollback are REUSED from the MTP path (decode_step_t,
469    /// cache.snapshot/rollback). Returns (tokens, total_drafted, total_accepted).
470    pub fn generate_spec_eagle(
471        &self,
472        e: &Engine,
473        draft: &Eagle3Draft,
474        prompt: &[u32],
475        max_new: usize,
476        k: usize,
477    ) -> Result<(Vec<u32>, usize, usize), Box<dyn std::error::Error>> {
478        self.refuse_hyper("generate_spec_eagle")?;
479        assert!(k >= 1, "k must be >= 1");
480        assert!(!prompt.is_empty(), "prompt must be non-empty");
481        let n_vocab = self.output.out_features();
482        validate_d2t_map(&draft.d2t, draft.draft_vocab, Some(n_vocab))?;
483        let n_embd = self.cfg.n_embd as usize;
484        assert_eq!(n_embd, draft.n_embd, "draft n_embd != target n_embd");
485        let aux = &draft.aux_layers;
486        let max_ctx = prompt.len() + max_new + k + 8;
487        let mut cache = Cache::new(e, &self.cfg, max_ctx)?;
488
489        // prime: feed the prompt; capture the LAST token's aux hiddens (seed for round-1 encode).
490        let mut prime_logits = Vec::new();
491        let mut prime_aux: Vec<CudaSlice<f32>> = Vec::new();
492        for &tok in prompt {
493            let (l, a) = self.decode_step_aux(e, tok, &mut cache, aux)?;
494            prime_logits = l;
495            prime_aux = a;
496        }
497
498        let mut scratch = Eagle3Scratch::new(e, draft, k + 1)?;
499        let mut out: Vec<u32> = Vec::with_capacity(max_new);
500        let mut total_drafted = 0usize;
501        let mut total_accepted = 0usize;
502
503        // EAGLE3 token/hidden alignment (vLLM `llama_eagle3.py`/`cnets.py`): the draft pairs the
504        // aux hidden of position p with the EMBEDDING of the token at position p+1 (input_ids are the
505        // target tokens shifted left by one). So drafting the token after `last_token` (at pos p)
506        // uses g = encode(aux of the token BEFORE last_token, at pos p-1) and embed(last_token).
507        // MEMRA_EAGLE_ALIGN=0 forces the un-shifted MTP-style pairing (aux & embed both = last_token)
508        // for A/B comparison; default (1) is the EAGLE shift. The prime loop already gave us the
509        // aux of the prompt's last token (= the predecessor of `last_token`), so we keep it as
510        // `prev_aux` and roll it forward by one each round.
511        let shift = std::env::var("MEMRA_EAGLE_ALIGN")
512            .ok()
513            .map(|s| s != "0")
514            .unwrap_or(true);
515        let mut last_token = argmax(&prime_logits) as u32;
516        out.push(last_token);
517        // prev_aux = aux of the token at the position whose forward predicted `last_token`
518        // (= the prompt's last token for round 1). g_aux = aux of `last_token` itself.
519        let mut prev_aux = prime_aux;
520        let (mut last_logits, mut g_aux) = self.decode_step_aux(e, last_token, &mut cache, aux)?;
521
522        while out.len() < max_new {
523            let pos = cache.pos;
524            let snap = cache.snapshot(e)?;
525
526            // --- 1. ENCODE once: g0 = fc @ concat(aux). With the EAGLE shift, the seed aux is the
527            //        PREDECESSOR token's (paired with embed(last_token)); else last_token's own. ---
528            let seed_aux = if shift { &prev_aux } else { &g_aux };
529            let g0 = draft.encode(e, seed_aux)?;
530
531            // --- 2. DRAFT k tokens with the EAGLE3 draft (autoregressive, T=1 each) ---
532            scratch.reset();
533            let mut draft_toks: Vec<u32> = Vec::with_capacity(k);
534            let mut prev = last_token;
535            let mut g = g0;
536            for j in 0..k {
537                let (dl, g_next) = draft.draft_token(e, self, prev, &g, &mut scratch, pos + j)?;
538                let d_draft = argmax(&dl) as u32;
539                let d_target = draft.d2t_map(d_draft); // map draft-vocab id -> target-vocab id
540                draft_toks.push(d_target);
541                prev = d_target;
542                g = g_next;
543            }
544
545            // --- 3. VERIFY: one batched target forward over draft_toks (T=k). REUSED from MTP. ---
546            let tlogits = self.decode_step_t(e, &draft_toks, pos, &mut cache)?;
547
548            // --- 4. GREEDY ACCEPT (walk prefix, stop at first mismatch). REUSED logic. ---
549            let t_pred = |j: usize| -> u32 {
550                if j == 0 {
551                    argmax(&last_logits) as u32
552                } else {
553                    argmax(&tlogits[(j - 1) * n_vocab..j * n_vocab]) as u32
554                }
555            };
556            let mut n_acc = 0usize;
557            #[allow(clippy::needless_range_loop)]
558            // allow: the explicit index loop keeps the offset arithmetic visible and aligned with the device-side indexing
559            for j in 0..k {
560                if t_pred(j) == draft_toks[j] {
561                    n_acc += 1;
562                } else {
563                    break;
564                }
565            }
566            let bonus = t_pred(n_acc);
567            total_drafted += k;
568            total_accepted += n_acc;
569
570            // --- 5. COMMIT draft[0..n_acc] then bonus ---
571            #[allow(clippy::needless_range_loop)]
572            // allow: the explicit index loop keeps the offset arithmetic visible and aligned with the device-side indexing
573            for j in 0..n_acc {
574                if out.len() >= max_new {
575                    break;
576                }
577                out.push(draft_toks[j]);
578            }
579            let bonus_emitted = out.len() < max_new;
580            if bonus_emitted {
581                out.push(bonus);
582            }
583            last_token = bonus;
584
585            // --- 6. ROLLBACK + advance to pos + n_acc + 1 committed tokens (REUSED from MTP). The
586            //        next round's EAGLE seed needs TWO auxs: g_aux = aux(bonus) and prev_aux =
587            //        aux(bonus's predecessor). bonus's predecessor is the last committed token BEFORE
588            //        bonus = draft[n_acc-1] if n_acc>=1, else this round's `last_token` (its aux is
589            //        the CURRENT g_aux). We always replay [committed-tail.. , bonus] aux-capturing so
590            //        the predecessor's aux is the second-to-last column; this keeps both exact.
591            let pred_is_prev_round = n_acc == 0; // bonus's predecessor = old last_token
592            let old_g_aux = std::mem::take(&mut g_aux); // = aux(old last_token)
593            // Unified exact path (also covers full-accept n_acc==k): restore the pre-round snapshot
594            // then replay the committed prefix draft[0..n_acc] ++ [bonus] as ONE T=(n_acc+1) aux-
595            // capturing forward — single weight read, bit-identical to greedy (verify-all-columns
596            // math). Captures aux at the last column (bonus) and, when the predecessor of bonus is a
597            // replayed token (n_acc>=1), the second-to-last column.
598            cache.rollback(e, &snap, 0)?;
599            let mut replay: Vec<u32> = draft_toks[0..n_acc].to_vec();
600            replay.push(bonus);
601            let pred_col = if pred_is_prev_round {
602                None
603            } else {
604                Some(replay.len() - 2)
605            };
606            let (rl, mut a_last, a_pred) =
607                self.decode_step_t_aux2(e, &replay, pos, &mut cache, aux, pred_col)?;
608            last_logits = rl[(replay.len() - 1) * n_vocab..replay.len() * n_vocab].to_vec();
609            prev_aux = if pred_is_prev_round {
610                old_g_aux
611            } else {
612                a_pred.unwrap()
613            };
614            g_aux = std::mem::take(&mut a_last);
615        }
616        out.truncate(max_new);
617        Ok((out, total_drafted, total_accepted))
618    }
619}
620
621// ============================ draft config.json (geometry + rope) ============================
622
623struct EagleConfig {
624    hidden_size: usize,
625    n_head: usize,
626    n_head_kv: usize,
627    head_dim: usize,
628    intermediate_size: usize,
629    draft_vocab: usize,
630    /// Explicit rotary dim count (`rotary_dim`, the MiniMax-M3 spelling). `None` on every
631    /// published EAGLE3 draft today; read anyway because the trunk readers honour it and a
632    /// draft config that declares it must not be silently ignored here.
633    rotary_dim: Option<u32>,
634    /// Fraction of `head_dim` that rotates (`partial_rotary_factor`, the Qwen3.5-family
635    /// spelling; eagle3-qwen35-9b declares 0.25 both top-level and under `rope_parameters`).
636    /// `None` means the config declares no partial rotary — full rope, resolved by
637    /// `resolve_rope_dim_count`, NOT defaulted to 1.0 here so the absent/malformed arms take
638    /// the same path the GGUF and HF/safetensors readers take.
639    partial_rotary_factor: Option<f32>,
640    rope_theta: f32,
641    rms_eps: f32,
642    aux_layers: Vec<usize>,
643}
644
645impl EagleConfig {
646    /// Rotary width for the draft attention: `resolve_rope_dim_count`, the ONE derivation the
647    /// GGUF and HF/safetensors readers already share (explicit dims > fraction > full width;
648    /// malformed fractions take the full width instead of a silently odd rotation). This used
649    /// to be a third, parallel implementation — `partial_rotary_factor.unwrap_or(1.0) *
650    /// head_dim`, no `rotary_dim`, no malformed-factor refusal — which is exactly the
651    /// two-implementations-drift class that gave the HF trunk path full rope on qwen3_5*
652    /// while its GGUF twin was correct (hermes finding d3a9414b560416b5).
653    fn rope_dim_count(&self) -> usize {
654        memra_gguf::config::resolve_rope_dim_count(
655            self.rotary_dim,
656            self.partial_rotary_factor,
657            self.head_dim as u32,
658        ) as usize
659    }
660
661    fn from_json(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
662        Self::from_json_str(&std::fs::read_to_string(path)?)
663    }
664
665    fn from_json_str(txt: &str) -> Result<Self, Box<dyn std::error::Error>> {
666        // Minimal field extraction (avoid a serde dep here; the draft config.json is flat-ish).
667        let num = |key: &str| -> Option<f64> {
668            let pat = format!("\"{key}\"");
669            let i = txt.find(&pat)? + pat.len();
670            let rest = &txt[i..];
671            let c = rest.find(':')? + 1;
672            let tail = rest[c..].trim_start();
673            let end = tail.find([',', '}', '\n']).unwrap_or(tail.len());
674            tail[..end].trim().parse::<f64>().ok()
675        };
676        let aux_layers: Vec<usize> = {
677            // eagle_aux_hidden_state_layer_ids: [1, 15, 28]
678            let pat = "\"eagle_aux_hidden_state_layer_ids\"";
679            match txt.find(pat) {
680                Some(i) => {
681                    let rest = &txt[i + pat.len()..];
682                    let lb = rest.find('[').ok_or("no [ after aux ids")?;
683                    let rb = rest.find(']').ok_or("no ] after aux ids")?;
684                    rest[lb + 1..rb]
685                        .split(',')
686                        .filter_map(|s| s.trim().parse::<usize>().ok())
687                        .collect()
688                }
689                None => vec![1, 15, 28], // fall back to the known EAGLE3-qwen35-9b layers
690            }
691        };
692        Ok(EagleConfig {
693            hidden_size: num("hidden_size").ok_or("hidden_size")? as usize,
694            n_head: num("num_attention_heads").ok_or("num_attention_heads")? as usize,
695            n_head_kv: num("num_key_value_heads").ok_or("num_key_value_heads")? as usize,
696            head_dim: num("head_dim").ok_or("head_dim")? as usize,
697            intermediate_size: num("intermediate_size").ok_or("intermediate_size")? as usize,
698            draft_vocab: num("draft_vocab_size").ok_or("draft_vocab_size")? as usize,
699            rotary_dim: num("rotary_dim").map(|v| v as u32),
700            partial_rotary_factor: num("partial_rotary_factor").map(|v| v as f32),
701            rope_theta: num("rope_theta").unwrap_or(10000.0) as f32,
702            rms_eps: num("rms_norm_eps").unwrap_or(1e-6) as f32,
703            aux_layers,
704        })
705    }
706}
707
708/// Read an i64 1-D tensor (d2t) from the draft safetensors.
709fn read_i64(m: &StModel, name: &str) -> Result<Vec<i64>, Box<dyn std::error::Error>> {
710    let (info, bytes) = m
711        .raw(name)
712        .ok_or_else(|| format!("EAGLE3 draft missing {name}"))?;
713    if info.dtype != "I64" {
714        return Err(format!("{name} dtype must be I64, found {}", info.dtype).into());
715    }
716    let ne = info.ne();
717    if ne.len() != 1 {
718        return Err(format!("{name} must be rank-1, found shape {ne:?}").into());
719    }
720    let n = usize::try_from(ne[0]).map_err(|_| format!("{name} length does not fit usize"))?;
721    let expected = n
722        .checked_mul(8)
723        .ok_or_else(|| format!("{name} byte length overflow"))?;
724    if bytes.len() != expected {
725        return Err(format!("{name} has {} bytes, expected {expected}", bytes.len()).into());
726    }
727    let mut v = Vec::with_capacity(n);
728    for i in 0..n {
729        v.push(i64::from_le_bytes(
730            bytes[i * 8..i * 8 + 8].try_into().unwrap(),
731        ));
732    }
733    Ok(v)
734}
735
736/// The draft loader's rope width shares `resolve_rope_dim_count` with the GGUF and HF readers —
737/// these tests pin that it stays ONE derivation (hermes d3a9414b560416b5, the lane that fixed the
738/// trunk HF path getting n_rot=256 where its GGUF twin said 64). CPU-only: config text in, width
739/// out, no device, no checkpoint.
740///
741/// The fixture is the REAL `eagle3-qwen35-9b/config.json` — the exact checkpoint this loader
742/// serves — verbatim, not a hand-written approximation. The trunk lane's postmortem: a fixture
743/// unrepresentative of every real instance of the arch it claims to model is how the suite came
744/// to bless full rope. Variant shapes below are derived from the real text by asserted edits, so
745/// a drifted fixture fails loudly instead of testing a config that no longer exists.
746#[cfg(test)]
747mod tensor_contract_tests {
748    use super::{
749        validate_aux_layers, validate_d2t_map, validate_eagle_attention_geometry,
750        validate_eagle_tensor,
751    };
752    use memra_gguf::safetensors::StInfo;
753
754    #[test]
755    fn every_eagle_weight_is_shape_and_dtype_checked_before_cuda() {
756        let valid = StInfo {
757            dtype: "BF16".into(),
758            shape: vec![8, 4],
759            data_offsets: [0, 64],
760        };
761        assert_eq!(validate_eagle_tensor("q", &valid, &[4, 8]).unwrap(), [4, 8]);
762        let mut bad = valid.clone();
763        bad.dtype = "I8".into();
764        assert!(
765            validate_eagle_tensor("q", &bad, &[4, 8])
766                .unwrap_err()
767                .contains("dtype")
768        );
769        bad = valid.clone();
770        bad.shape = vec![32];
771        assert!(
772            validate_eagle_tensor("q", &bad, &[4, 8])
773                .unwrap_err()
774                .contains("shape")
775        );
776    }
777
778    #[test]
779    fn auxiliary_and_vocabulary_maps_are_closed_before_cuda() {
780        assert!(validate_aux_layers(&[1, 15, 28]).is_ok());
781        for invalid in [&[1, 2][..], &[1, 1, 2], &[2, 1, 3], &[1, 2, 3, 4]] {
782            assert!(
783                validate_aux_layers(invalid).is_err(),
784                "accepted {invalid:?}"
785            );
786        }
787        assert!(validate_d2t_map(&[0, 1, -1], 3, Some(4)).is_ok());
788        assert!(validate_d2t_map(&[0], 2, None).is_err());
789        assert!(validate_d2t_map(&[-1], 1, None).is_err());
790        assert!(validate_d2t_map(&[i64::MAX], 1, None).is_err());
791        let err = validate_d2t_map(&[4], 1, Some(4)).unwrap_err();
792        assert!(err.contains("target vocabulary of 4 entries"), "{err}");
793        assert!(validate_eagle_attention_geometry(32, 8, 128).is_ok());
794        assert!(validate_eagle_attention_geometry(7, 8, 128).is_err());
795        assert!(validate_eagle_attention_geometry(8, 0, 128).is_err());
796        assert!(validate_eagle_attention_geometry(8, 8, 33).is_err());
797        assert!(validate_eagle_attention_geometry(usize::MAX, 1, 2).is_err());
798    }
799}
800
801#[cfg(test)]
802mod draft_rope_width_tests {
803    use super::EagleConfig;
804    use memra_gguf::config::{HfConfig, resolve_rope_dim_count};
805
806    /// Verbatim `~/ai-ml/hf-models/eagle3-qwen35-9b/config.json` (banked shape also in the lane
807    /// receipts, darklanes research/ornith-prep-20260819/N-ROT-FIX.md): `partial_rotary_factor`
808    /// 0.25 declared BOTH top-level and under `rope_parameters` (the Ornith spelling spread),
809    /// `head_dim` 256, and — like every published qwen3_5-family config — NO `rotary_dim`.
810    const EAGLE3_QWEN35_9B_CONFIG: &str = r#"{
811  "architectures": [
812    "LlamaForCausalLMEagle3"
813  ],
814  "attention_bias": false,
815  "attention_dropout": 0.0,
816  "bos_token_id": 248040,
817  "draft_vocab_size": 32000,
818  "dtype": "bfloat16",
819  "eos_token_id": 248044,
820  "head_dim": 256,
821  "hidden_act": "silu",
822  "hidden_size": 4096,
823  "initializer_range": 0.02,
824  "intermediate_size": 12288,
825  "max_position_embeddings": 262144,
826  "mlp_bias": false,
827  "model_type": "llama",
828  "num_attention_heads": 16,
829  "num_hidden_layers": 1,
830  "num_key_value_heads": 4,
831  "pad_token_id": null,
832  "partial_rotary_factor": 0.25,
833  "pretraining_tp": 1,
834  "rms_norm_eps": 1e-06,
835  "rope_parameters": {
836    "partial_rotary_factor": 0.25,
837    "rope_theta": 10000000,
838    "rope_type": "default"
839  },
840  "tie_word_embeddings": false,
841  "transformers_version": "5.3.0",
842  "use_cache": true,
843  "vocab_size": 248320,
844  "eagle_config": {
845    "use_aux_hidden_state": true,
846    "eagle_aux_hidden_state_layer_ids": [1, 15, 28]
847  }
848}"#;
849
850    /// Edit the fixture, refusing to no-op: a variant built by a replace that matched nothing
851    /// would silently test the unmodified shape.
852    fn edited(from: &str, to: &str) -> String {
853        assert!(
854            EAGLE3_QWEN35_9B_CONFIG.contains(from),
855            "fixture drifted: {from:?} not found — the variant below would test the wrong shape"
856        );
857        EAGLE3_QWEN35_9B_CONFIG.replace(from, to)
858    }
859
860    /// Both readers of one config must extract the same two rope facts. This is the divergence
861    /// gate — the same shape as the trunk lane's `n_rot_agrees_across_the_gguf_and_hf_loader_paths`
862    /// — because the draft reader is a hand-rolled scanner and `HfConfig::parse` is the structured
863    /// parser, and nothing else forces them to agree on what a config declares.
864    fn assert_reader_parity(json: &str) -> usize {
865        let draft = EagleConfig::from_json_str(json).expect("draft reader must parse the fixture");
866        let hf = HfConfig::parse(json);
867        assert_eq!(
868            draft.rotary_dim, hf.rotary_dim,
869            "draft scanner and HfConfig::parse disagree on rotary_dim for the same config"
870        );
871        assert_eq!(
872            draft.partial_rotary_factor, hf.partial_rotary_factor,
873            "draft scanner and HfConfig::parse disagree on partial_rotary_factor for the same config"
874        );
875        let expected = resolve_rope_dim_count(
876            hf.rotary_dim,
877            hf.partial_rotary_factor,
878            hf.head_dim.expect("fixture declares head_dim"),
879        ) as usize;
880        assert_eq!(
881            draft.rope_dim_count(),
882            expected,
883            "draft rope width diverged from the shared derivation on the same facts"
884        );
885        draft.rope_dim_count()
886    }
887
888    /// The teeth: a mutation that reintroduces full-rope derivation (ignoring the factor, or
889    /// multiplying an unwrap_or(1.0) default) fails HERE, on the real checkpoint's own config,
890    /// with the corrupted band named.
891    #[test]
892    fn real_eagle3_qwen35_9b_config_derives_partial_rope_64_of_256() {
893        let cfg = EagleConfig::from_json_str(EAGLE3_QWEN35_9B_CONFIG).expect("real config parses");
894        assert_eq!(cfg.head_dim, 256);
895        assert_eq!(
896            cfg.rotary_dim, None,
897            "no published EAGLE3 draft declares rotary_dim"
898        );
899        assert_eq!(
900            cfg.partial_rotary_factor,
901            Some(0.25),
902            "the declared factor must be READ, not defaulted — unwrap_or(1.0) is the bug class"
903        );
904        assert_eq!(
905            cfg.rope_dim_count(),
906            64,
907            "eagle3-qwen35-9b rotates 64 of 256 head dims; full rope silently corrupts the \
908             pass-through band 64..256 — no shape error, fluent output, wrecked long context"
909        );
910        assert_eq!(assert_reader_parity(EAGLE3_QWEN35_9B_CONFIG), 64);
911    }
912
913    /// The Qwen3.5-122B spelling: the factor ONLY under `rope_parameters`, nothing top-level.
914    /// A rewrite of the scanner that reads only the top-level key regresses exactly here.
915    #[test]
916    fn nested_only_partial_rotary_spelling_is_still_partial_rope() {
917        let json = edited("\n  \"partial_rotary_factor\": 0.25,", "");
918        let cfg = EagleConfig::from_json_str(&json).expect("nested-only config parses");
919        assert_eq!(
920            cfg.partial_rotary_factor,
921            Some(0.25),
922            "rope_parameters spelling must be read"
923        );
924        assert_eq!(cfg.rope_dim_count(), 64);
925        assert_reader_parity(&json);
926    }
927
928    /// The honest default, isolated (the trunk lane's
929    /// `qwen35_hf_without_a_partial_rotary_declaration_is_full_rope` twin): no declaration at
930    /// all means full rope, and this case must never be conflated with the partial answer.
931    #[test]
932    fn no_rope_declaration_is_full_rope() {
933        let json = edited("\n  \"partial_rotary_factor\": 0.25,", "")
934            .replace("\n    \"partial_rotary_factor\": 0.25,", "");
935        assert!(
936            !json.contains("partial_rotary_factor"),
937            "variant edit failed: a factor spelling survived"
938        );
939        let cfg = EagleConfig::from_json_str(&json).expect("undeclared-rope config parses");
940        assert_eq!(cfg.partial_rotary_factor, None);
941        assert_eq!(
942            cfg.rope_dim_count(),
943            256,
944            "absent declaration = every head dim rotates"
945        );
946        assert_reader_parity(&json);
947    }
948
949    /// Explicit dims beat the fraction — the shared precedence. The old draft code read ONLY the
950    /// fraction, so a draft config carrying `rotary_dim` (the MiniMax-M3 spelling, what a
951    /// converter writes once it has resolved the fraction) was silently ignored. A mutation back
952    /// to factor-only arithmetic fails here.
953    #[test]
954    fn explicit_rotary_dim_wins_over_the_fraction() {
955        let json = edited(
956            "\n  \"partial_rotary_factor\": 0.25,",
957            "\n  \"partial_rotary_factor\": 0.25,\n  \"rotary_dim\": 32,",
958        );
959        let cfg = EagleConfig::from_json_str(&json).expect("explicit-dims config parses");
960        assert_eq!(cfg.rotary_dim, Some(32));
961        assert_eq!(
962            cfg.rope_dim_count(),
963            32,
964            "explicit rotary_dim is the more specific declaration and must win over the fraction"
965        );
966        assert_reader_parity(&json);
967    }
968
969    /// Malformed fractions refuse to truncate — same posture as the trunk readers. The OLD draft
970    /// arithmetic multiplied the raw factor: 2.0 * 256 = a 512-dim rotation over a 256-dim head
971    /// (writing past the head), and 0.0 * 256 -> max(2) = a 2-dim rotation that silently
972    /// disables rope while looking like a plausible model.
973    #[test]
974    fn malformed_factor_takes_full_width_not_a_wider_than_head_rotation() {
975        let over = EAGLE3_QWEN35_9B_CONFIG.replace(
976            "\"partial_rotary_factor\": 0.25",
977            "\"partial_rotary_factor\": 2.0",
978        );
979        let cfg = EagleConfig::from_json_str(&over).expect("factor-2.0 config parses");
980        assert_eq!(cfg.partial_rotary_factor, Some(2.0));
981        assert_eq!(
982            cfg.rope_dim_count(),
983            256,
984            "factor 2.0 must take the FULL head width (256), never 512 — the old \
985             factor*head_dim arithmetic rotated past the head allocation"
986        );
987        assert_reader_parity(&over);
988
989        let zero = EAGLE3_QWEN35_9B_CONFIG.replace(
990            "\"partial_rotary_factor\": 0.25",
991            "\"partial_rotary_factor\": 0.0",
992        );
993        let cfg = EagleConfig::from_json_str(&zero).expect("factor-0.0 config parses");
994        assert_eq!(
995            cfg.rope_dim_count(),
996            256,
997            "factor 0.0 is malformed and takes the full width, not the old max(2) stub rotation"
998        );
999        assert_reader_parity(&zero);
1000    }
1001}