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::cache::{Cache, KvLayer};
41use crate::forward::argmax;
42use crate::hybrid::HybridModel;
43use crate::model::GpuTensor;
44use crate::Engine;
45use memra_gguf::dequant;
46use memra_gguf::safetensors::StModel;
47use cudarc::driver::CudaSlice;
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, // partial_rotary_factor * head_dim  (0.25 * 256 = 64)
78    pub rope_theta: f32,       // 1e7
79    pub eps: f32,
80    pub aux_layers: Vec<usize>, // [1, 15, 28]
81}
82
83/// Load a single bf16 (or f32) tensor from the draft safetensors into a GpuTensor::Float.
84/// `name` is the raw HF/EAGLE name in the file (e.g. "fc.weight", "midlayer.self_attn.q_proj.weight").
85fn load_float(
86    e: &Engine,
87    m: &StModel,
88    name: &str,
89) -> Result<GpuTensor, Box<dyn std::error::Error>> {
90    let (info, bytes) = m
91        .raw(name)
92        .ok_or_else(|| format!("EAGLE3 draft missing tensor {name}"))?;
93    let ne = info.ne(); // inner-fastest (ne[0]=in_features for a weight)
94    let n: u64 = ne.iter().product();
95    let f32v = dequant::dequantize(info.ggml_type(), bytes, n as usize);
96    Ok(GpuTensor::Float {
97        data: e.htod(&f32v)?,
98        ne,
99    })
100}
101
102impl Eagle3Draft {
103    /// Load the EAGLE3 draft from a checkpoint directory (config.json + model.safetensors) or a
104    /// direct path to the .safetensors. Reads the geometry/rope params from the sibling config.json.
105    /// `aux_layers` is the trunk layer-id list from `eagle_config.eagle_aux_hidden_state_layer_ids`.
106    pub fn load(e: &Engine, path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
107        let dir = if path.is_file() {
108            path.parent().unwrap_or(Path::new("."))
109        } else {
110            path
111        };
112        let cfg = EagleConfig::from_json(&dir.join("config.json"))?;
113        let m = StModel::open(path)?;
114
115        let d2t = read_i64(&m, "d2t")?;
116        assert_eq!(d2t.len(), cfg.draft_vocab, "d2t len != draft_vocab_size");
117
118        let draft = Eagle3Draft {
119            fc: load_float(e, &m, "fc.weight")?,
120            input_layernorm: load_float(e, &m, "midlayer.input_layernorm.weight")?,
121            hidden_norm: load_float(e, &m, "midlayer.hidden_norm.weight")?,
122            q_proj: load_float(e, &m, "midlayer.self_attn.q_proj.weight")?,
123            k_proj: load_float(e, &m, "midlayer.self_attn.k_proj.weight")?,
124            v_proj: load_float(e, &m, "midlayer.self_attn.v_proj.weight")?,
125            o_proj: load_float(e, &m, "midlayer.self_attn.o_proj.weight")?,
126            post_attention_layernorm: load_float(
127                e,
128                &m,
129                "midlayer.post_attention_layernorm.weight",
130            )?,
131            gate_proj: load_float(e, &m, "midlayer.mlp.gate_proj.weight")?,
132            up_proj: load_float(e, &m, "midlayer.mlp.up_proj.weight")?,
133            down_proj: load_float(e, &m, "midlayer.mlp.down_proj.weight")?,
134            norm: load_float(e, &m, "norm.weight")?,
135            lm_head: load_float(e, &m, "lm_head.weight")?,
136            d2t,
137            n_embd: cfg.hidden_size,
138            n_head: cfg.n_head,
139            n_head_kv: cfg.n_head_kv,
140            head_dim: cfg.head_dim,
141            n_ff: cfg.intermediate_size,
142            draft_vocab: cfg.draft_vocab,
143            rope_dim_count: ((cfg.partial_rotary_factor * cfg.head_dim as f32).round() as usize)
144                .max(2),
145            rope_theta: cfg.rope_theta,
146            eps: cfg.rms_eps,
147            aux_layers: cfg.aux_layers,
148        };
149        // shape sanity (catches a wrong checkpoint / mapping):
150        assert_eq!(
151            draft.fc.in_features(),
152            3 * draft.n_embd,
153            "fc in != 3*n_embd"
154        );
155        assert_eq!(draft.fc.out_features(), draft.n_embd, "fc out != n_embd");
156        assert_eq!(
157            draft.q_proj.in_features(),
158            2 * draft.n_embd,
159            "q_proj in != 2*n_embd"
160        );
161        assert_eq!(
162            draft.q_proj.out_features(),
163            draft.n_head * draft.head_dim,
164            "q_proj out"
165        );
166        assert_eq!(
167            draft.lm_head.out_features(),
168            draft.draft_vocab,
169            "lm_head out != draft_vocab"
170        );
171        Ok(draft)
172    }
173
174    /// Map a DRAFT-vocab id to a TARGET-vocab id (d2t is a DELTA: target = draft + d2t[draft]).
175    #[inline]
176    pub fn d2t_map(&self, draft_id: u32) -> u32 {
177        (draft_id as i64 + self.d2t[draft_id as usize]) as u32
178    }
179
180    /// ENCODE (once per round, EAGLE-PLAN N3): g = fc @ concat(aux0, aux1, aux2). `aux` are the 3
181    /// trunk residual hiddens of the just-committed token (decode_step_aux / decode_step_t_aux),
182    /// in ascending-layer order. Returns the recurrent draft hidden `g` [n_embd].
183    pub fn encode(
184        &self,
185        e: &Engine,
186        aux: &[CudaSlice<f32>],
187    ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
188        assert_eq!(aux.len(), self.aux_layers.len(), "aux count != #aux layers");
189        let n = self.n_embd;
190        let mut cat = e.zeros(self.aux_layers.len() * n)?;
191        for (i, a) in aux.iter().enumerate() {
192            e.copy_into(&mut cat, i * n, a, n)?;
193        }
194        e.matmul(&self.fc, &cat, 1) // [3*n_embd] @ fc[3n_embd,n_embd] -> [n_embd]
195    }
196
197    /// One DRAFT-token forward (EAGLE-PLAN N4, T=1). `prev_tok` = the TARGET token id to predict
198    /// from (last committed or previous draft). `g` = the recurrent draft hidden (encode() output
199    /// on round entry, then the previous step's g_next). Returns (draft_logits[draft_vocab] host,
200    /// g_next dev). Mirrors the vLLM op-sequence documented at the top of this file.
201    pub fn draft_token(
202        &self,
203        e: &Engine,
204        target: &HybridModel,
205        prev_tok: u32,
206        g: &CudaSlice<f32>,
207        scratch: &mut Eagle3Scratch,
208        pos: usize,
209    ) -> Result<(Vec<f32>, CudaSlice<f32>), Box<dyn std::error::Error>> {
210        let n = self.n_embd;
211        let eps = self.eps;
212        let pos_d = e.htod_i32(&[pos as i32])?;
213
214        // e = TARGET embedding of prev_tok (EAGLE3 shares the target's token embedding).
215        // eN = input_layernorm(e); gN = hidden_norm(g); residual = PRE-norm g (norm_after_residual).
216        let e_emb = e.htod(&target.embd.gather(n, &[prev_tok]))?;
217        let mut e_norm = e.zeros(n)?;
218        e.rms_norm(
219            &e_emb,
220            self.input_layernorm.float_data(),
221            &mut e_norm,
222            n,
223            1,
224            eps,
225        )?;
226        let res = e.clone_dtod(g)?;
227        let mut g_norm = e.zeros(n)?;
228        e.rms_norm(g, self.hidden_norm.float_data(), &mut g_norm, n, 1, eps)?;
229        // cat = [eN ; gN] -> [2*n_embd]  (vLLM llama_eagle3: torch.cat([embeds, hidden_states])).
230        let mut cat = e.zeros(2 * n)?;
231        e.copy_into(&mut cat, 0, &e_norm, n)?;
232        e.copy_into(&mut cat, n, &g_norm, n)?;
233
234        // attention from the 2*n_embd concat (plain Llama: no QK-norm, no output gate).
235        let attn = self.attn(e, &cat, &pos_d, scratch)?;
236        // x1 = attn + residual(g)
237        let mut x1 = e.zeros(n)?;
238        e.add(&attn, &res, &mut x1, n)?;
239        // z = post_attention_layernorm(x1)
240        let mut z = e.zeros(n)?;
241        e.rms_norm(
242            &x1,
243            self.post_attention_layernorm.float_data(),
244            &mut z,
245            n,
246            1,
247            eps,
248        )?;
249        // mlp = down @ (silu(gate@z) * (up@z))
250        let gate = e.matmul(&self.gate_proj, &z, 1)?;
251        let up = e.matmul(&self.up_proj, &z, 1)?;
252        let mut act = e.zeros(self.n_ff)?;
253        e.silu_mul(&gate, &up, &mut act, self.n_ff)?;
254        let mlp = e.matmul(&self.down_proj, &act, 1)?;
255        // g_next = mlp + x1  (final fused-add residual; this is the aux_output recurrence)
256        let mut g_next = e.zeros(n)?;
257        e.add(&mlp, &x1, &mut g_next, n)?;
258        // dl = lm_head @ norm(g_next)
259        let mut hn = e.zeros(n)?;
260        e.rms_norm(&g_next, self.norm.float_data(), &mut hn, n, 1, eps)?;
261        let logits = e.matmul(&self.lm_head, &hn, 1)?;
262        let host = e.dtoh(&logits)?;
263        Ok((host, g_next))
264    }
265
266    /// Plain Llama attention over the [2*n_embd] concat input, T=1, on the draft's own scratch KV.
267    /// q/k/v project from 2*n_embd; partial RoPE (rope_dim_count of head_dim) at the draft theta;
268    /// GQA broadcast in fa_decode; o_proj back to n_embd. No QK-norm, no output gate.
269    fn attn(
270        &self,
271        e: &Engine,
272        cat: &CudaSlice<f32>,
273        pos_d: &CudaSlice<i32>,
274        scratch: &mut Eagle3Scratch,
275    ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
276        let (nh, nhkv, hd) = (self.n_head, self.n_head_kv, self.head_dim);
277        let scale = 1.0 / (hd as f32).sqrt();
278        let mut q = e.matmul(&self.q_proj, cat, 1)?; // [nh*hd]
279        let mut k = e.matmul(&self.k_proj, cat, 1)?; // [nhkv*hd]
280        let v = e.matmul(&self.v_proj, cat, 1)?; // [nhkv*hd]
281
282        // partial RoPE: rope_dim_count = partial_rotary_factor * head_dim (= 64 of 256), draft theta.
283        e.rope_neox(
284            &mut q,
285            pos_d,
286            hd,
287            self.rope_dim_count,
288            nh,
289            1,
290            self.rope_theta,
291            1.0,
292        )?;
293        e.rope_neox(
294            &mut k,
295            pos_d,
296            hd,
297            self.rope_dim_count,
298            nhkv,
299            1,
300            self.rope_theta,
301            1.0,
302        )?;
303
304        let kv = &mut scratch.kv;
305        e.append_kv_quantized(
306            &k,
307            &v,
308            &mut kv.k,
309            &mut kv.v,
310            kv.len,
311            kv.kv_dim_k,
312            kv.kv_dim_v,
313            kv.k_tok_bytes,
314            kv.v_tok_bytes,
315            false,
316        )?;
317        kv.len += 1;
318        let t_kv = kv.len;
319        let (ktb, vtb) = (kv.k_tok_bytes, kv.v_tok_bytes);
320        let k_view = e.view_u8(&kv.k, t_kv * ktb);
321        let v_view = e.view_u8(&kv.v, t_kv * vtb);
322        let mut attn = e.zeros(nh * hd)?;
323        e.fa_decode(
324            &q, &k_view, &v_view, &mut attn, hd, nh, nhkv, t_kv, scale, ktb, vtb,
325        )?;
326        e.matmul(&self.o_proj, &attn, 1)
327    }
328}
329
330/// Tiny scratch KV for the EAGLE3 draft layer (one full-attn layer). Reset each draft round. Uses
331/// the SAME q8_0-K / q5_1-V quantized layout as the trunk KV (head_dim%32==0 holds: 256).
332pub struct Eagle3Scratch {
333    pub kv: KvLayer,
334}
335impl Eagle3Scratch {
336    pub fn new(
337        e: &Engine,
338        draft: &Eagle3Draft,
339        cap: usize,
340    ) -> Result<Self, Box<dyn std::error::Error>> {
341        let (nhkv, hd) = (draft.n_head_kv, draft.head_dim);
342        assert!(
343            hd % 32 == 0,
344            "KVQUANT requires head_dim%32==0 (EAGLE3 scratch)"
345        );
346        let kv_dim_k = hd * nhkv;
347        let kv_dim_v = hd * nhkv;
348        let (kbb, vbb) = crate::kv_blk_bytes(); // env-selected KV formats (default 34/24)
349        let k_tok_bytes = (kv_dim_k / 32) * kbb;
350        let v_tok_bytes = (kv_dim_v / 32) * vbb;
351        Ok(Eagle3Scratch {
352            kv: KvLayer {
353                k: e.alloc_u8(cap * k_tok_bytes)?,
354                v: e.alloc_u8(cap * v_tok_bytes)?,
355                kv_dim_k,
356                kv_dim_v,
357                k_tok_bytes,
358                v_tok_bytes,
359                len: 0,
360                len_d: e.htod_i32(&[0])?,
361            },
362        })
363    }
364    pub fn reset(&mut self) {
365        self.kv.len = 0;
366    }
367}
368
369impl HybridModel {
370    /// Greedy EAGLE3 speculative decode (EAGLE-PLAN N6). Token-identical to `generate(prompt,n)`
371    /// but drafts K tokens with the separate EAGLE3 draft, then verifies them in ONE batched target
372    /// forward. Verify/accept/snapshot/rollback are REUSED from the MTP path (decode_step_t,
373    /// cache.snapshot/rollback). Returns (tokens, total_drafted, total_accepted).
374    pub fn generate_spec_eagle(
375        &self,
376        e: &Engine,
377        draft: &Eagle3Draft,
378        prompt: &[u32],
379        max_new: usize,
380        k: usize,
381    ) -> Result<(Vec<u32>, usize, usize), Box<dyn std::error::Error>> {
382        assert!(k >= 1, "k must be >= 1");
383        assert!(!prompt.is_empty(), "prompt must be non-empty");
384        let n_vocab = self.output.out_features();
385        let n_embd = self.cfg.n_embd as usize;
386        assert_eq!(n_embd, draft.n_embd, "draft n_embd != target n_embd");
387        let aux = &draft.aux_layers;
388        let max_ctx = prompt.len() + max_new + k + 8;
389        let mut cache = Cache::new(e, &self.cfg, max_ctx)?;
390
391        // prime: feed the prompt; capture the LAST token's aux hiddens (seed for round-1 encode).
392        let mut prime_logits = Vec::new();
393        let mut prime_aux: Vec<CudaSlice<f32>> = Vec::new();
394        for &tok in prompt {
395            let (l, a) = self.decode_step_aux(e, tok, &mut cache, aux)?;
396            prime_logits = l;
397            prime_aux = a;
398        }
399
400        let mut scratch = Eagle3Scratch::new(e, draft, k + 1)?;
401        let mut out: Vec<u32> = Vec::with_capacity(max_new);
402        let mut total_drafted = 0usize;
403        let mut total_accepted = 0usize;
404
405        // EAGLE3 token/hidden alignment (vLLM `llama_eagle3.py`/`cnets.py`): the draft pairs the
406        // aux hidden of position p with the EMBEDDING of the token at position p+1 (input_ids are the
407        // target tokens shifted left by one). So drafting the token after `last_token` (at pos p)
408        // uses g = encode(aux of the token BEFORE last_token, at pos p-1) and embed(last_token).
409        // MEMRA_EAGLE_ALIGN=0 forces the un-shifted MTP-style pairing (aux & embed both = last_token)
410        // for A/B comparison; default (1) is the EAGLE shift. The prime loop already gave us the
411        // aux of the prompt's last token (= the predecessor of `last_token`), so we keep it as
412        // `prev_aux` and roll it forward by one each round.
413        let shift = std::env::var("MEMRA_EAGLE_ALIGN")
414            .ok()
415            .map(|s| s != "0")
416            .unwrap_or(true);
417        let mut last_token = argmax(&prime_logits) as u32;
418        out.push(last_token);
419        // prev_aux = aux of the token at the position whose forward predicted `last_token`
420        // (= the prompt's last token for round 1). g_aux = aux of `last_token` itself.
421        let mut prev_aux = prime_aux;
422        let (mut last_logits, mut g_aux) = self.decode_step_aux(e, last_token, &mut cache, aux)?;
423
424        while out.len() < max_new {
425            let pos = cache.pos;
426            let snap = cache.snapshot(e)?;
427
428            // --- 1. ENCODE once: g0 = fc @ concat(aux). With the EAGLE shift, the seed aux is the
429            //        PREDECESSOR token's (paired with embed(last_token)); else last_token's own. ---
430            let seed_aux = if shift { &prev_aux } else { &g_aux };
431            let g0 = draft.encode(e, seed_aux)?;
432
433            // --- 2. DRAFT k tokens with the EAGLE3 draft (autoregressive, T=1 each) ---
434            scratch.reset();
435            let mut draft_toks: Vec<u32> = Vec::with_capacity(k);
436            let mut prev = last_token;
437            let mut g = g0;
438            for j in 0..k {
439                let (dl, g_next) = draft.draft_token(e, self, prev, &g, &mut scratch, pos + j)?;
440                let d_draft = argmax(&dl) as u32;
441                let d_target = draft.d2t_map(d_draft); // map draft-vocab id -> target-vocab id
442                draft_toks.push(d_target);
443                prev = d_target;
444                g = g_next;
445            }
446
447            // --- 3. VERIFY: one batched target forward over draft_toks (T=k). REUSED from MTP. ---
448            let tlogits = self.decode_step_t(e, &draft_toks, pos, &mut cache)?;
449
450            // --- 4. GREEDY ACCEPT (walk prefix, stop at first mismatch). REUSED logic. ---
451            let t_pred = |j: usize| -> u32 {
452                if j == 0 {
453                    argmax(&last_logits) as u32
454                } else {
455                    argmax(&tlogits[(j - 1) * n_vocab..j * n_vocab]) as u32
456                }
457            };
458            let mut n_acc = 0usize;
459            for j in 0..k {
460                if t_pred(j) == draft_toks[j] {
461                    n_acc += 1;
462                } else {
463                    break;
464                }
465            }
466            let bonus = t_pred(n_acc);
467            total_drafted += k;
468            total_accepted += n_acc;
469
470            // --- 5. COMMIT draft[0..n_acc] then bonus ---
471            for j in 0..n_acc {
472                if out.len() >= max_new {
473                    break;
474                }
475                out.push(draft_toks[j]);
476            }
477            let bonus_emitted = out.len() < max_new;
478            if bonus_emitted {
479                out.push(bonus);
480            }
481            last_token = bonus;
482
483            // --- 6. ROLLBACK + advance to pos + n_acc + 1 committed tokens (REUSED from MTP). The
484            //        next round's EAGLE seed needs TWO auxs: g_aux = aux(bonus) and prev_aux =
485            //        aux(bonus's predecessor). bonus's predecessor is the last committed token BEFORE
486            //        bonus = draft[n_acc-1] if n_acc>=1, else this round's `last_token` (its aux is
487            //        the CURRENT g_aux). We always replay [committed-tail.. , bonus] aux-capturing so
488            //        the predecessor's aux is the second-to-last column; this keeps both exact.
489            let pred_is_prev_round = n_acc == 0; // bonus's predecessor = old last_token
490            let old_g_aux = std::mem::take(&mut g_aux); // = aux(old last_token)
491                                                        // Unified exact path (also covers full-accept n_acc==k): restore the pre-round snapshot
492                                                        // then replay the committed prefix draft[0..n_acc] ++ [bonus] as ONE T=(n_acc+1) aux-
493                                                        // capturing forward — single weight read, bit-identical to greedy (verify-all-columns
494                                                        // math). Captures aux at the last column (bonus) and, when the predecessor of bonus is a
495                                                        // replayed token (n_acc>=1), the second-to-last column.
496            cache.rollback(e, &snap, 0)?;
497            let mut replay: Vec<u32> = draft_toks[0..n_acc].to_vec();
498            replay.push(bonus);
499            let pred_col = if pred_is_prev_round {
500                None
501            } else {
502                Some(replay.len() - 2)
503            };
504            let (rl, mut a_last, a_pred) =
505                self.decode_step_t_aux2(e, &replay, pos, &mut cache, aux, pred_col)?;
506            last_logits = rl[(replay.len() - 1) * n_vocab..replay.len() * n_vocab].to_vec();
507            prev_aux = if pred_is_prev_round {
508                old_g_aux
509            } else {
510                a_pred.unwrap()
511            };
512            g_aux = std::mem::take(&mut a_last);
513        }
514        out.truncate(max_new);
515        Ok((out, total_drafted, total_accepted))
516    }
517}
518
519// ============================ draft config.json (geometry + rope) ============================
520
521struct EagleConfig {
522    hidden_size: usize,
523    n_head: usize,
524    n_head_kv: usize,
525    head_dim: usize,
526    intermediate_size: usize,
527    draft_vocab: usize,
528    partial_rotary_factor: f32,
529    rope_theta: f32,
530    rms_eps: f32,
531    aux_layers: Vec<usize>,
532}
533
534impl EagleConfig {
535    fn from_json(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
536        let txt = std::fs::read_to_string(path)?;
537        // Minimal field extraction (avoid a serde dep here; the draft config.json is flat-ish).
538        let num = |key: &str| -> Option<f64> {
539            let pat = format!("\"{key}\"");
540            let i = txt.find(&pat)? + pat.len();
541            let rest = &txt[i..];
542            let c = rest.find(':')? + 1;
543            let tail = rest[c..].trim_start();
544            let end = tail
545                .find(|ch: char| ch == ',' || ch == '}' || ch == '\n')
546                .unwrap_or(tail.len());
547            tail[..end].trim().parse::<f64>().ok()
548        };
549        let aux_layers: Vec<usize> = {
550            // eagle_aux_hidden_state_layer_ids: [1, 15, 28]
551            let pat = "\"eagle_aux_hidden_state_layer_ids\"";
552            match txt.find(pat) {
553                Some(i) => {
554                    let rest = &txt[i + pat.len()..];
555                    let lb = rest.find('[').ok_or("no [ after aux ids")?;
556                    let rb = rest.find(']').ok_or("no ] after aux ids")?;
557                    rest[lb + 1..rb]
558                        .split(',')
559                        .filter_map(|s| s.trim().parse::<usize>().ok())
560                        .collect()
561                }
562                None => vec![1, 15, 28], // fall back to the known EAGLE3-qwen35-9b layers
563            }
564        };
565        Ok(EagleConfig {
566            hidden_size: num("hidden_size").ok_or("hidden_size")? as usize,
567            n_head: num("num_attention_heads").ok_or("num_attention_heads")? as usize,
568            n_head_kv: num("num_key_value_heads").ok_or("num_key_value_heads")? as usize,
569            head_dim: num("head_dim").ok_or("head_dim")? as usize,
570            intermediate_size: num("intermediate_size").ok_or("intermediate_size")? as usize,
571            draft_vocab: num("draft_vocab_size").ok_or("draft_vocab_size")? as usize,
572            partial_rotary_factor: num("partial_rotary_factor").unwrap_or(1.0) as f32,
573            rope_theta: num("rope_theta").unwrap_or(10000.0) as f32,
574            rms_eps: num("rms_norm_eps").unwrap_or(1e-6) as f32,
575            aux_layers,
576        })
577    }
578}
579
580/// Read an i64 1-D tensor (d2t) from the draft safetensors.
581fn read_i64(m: &StModel, name: &str) -> Result<Vec<i64>, Box<dyn std::error::Error>> {
582    let (info, bytes) = m
583        .raw(name)
584        .ok_or_else(|| format!("EAGLE3 draft missing {name}"))?;
585    assert_eq!(info.dtype, "I64", "{name} dtype != I64");
586    let n = bytes.len() / 8;
587    let mut v = Vec::with_capacity(n);
588    for i in 0..n {
589        v.push(i64::from_le_bytes(
590            bytes[i * 8..i * 8 + 8].try_into().unwrap(),
591        ));
592    }
593    Ok(v)
594}