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
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.rope_dim_count(),
144 rope_theta: cfg.rope_theta,
145 eps: cfg.rms_eps,
146 aux_layers: cfg.aux_layers,
147 };
148 // shape sanity (catches a wrong checkpoint / mapping):
149 assert_eq!(
150 draft.fc.in_features(),
151 3 * draft.n_embd,
152 "fc in != 3*n_embd"
153 );
154 assert_eq!(draft.fc.out_features(), draft.n_embd, "fc out != n_embd");
155 assert_eq!(
156 draft.q_proj.in_features(),
157 2 * draft.n_embd,
158 "q_proj in != 2*n_embd"
159 );
160 assert_eq!(
161 draft.q_proj.out_features(),
162 draft.n_head * draft.head_dim,
163 "q_proj out"
164 );
165 assert_eq!(
166 draft.lm_head.out_features(),
167 draft.draft_vocab,
168 "lm_head out != draft_vocab"
169 );
170 Ok(draft)
171 }
172
173 /// Map a DRAFT-vocab id to a TARGET-vocab id (d2t is a DELTA: target = draft + d2t[draft]).
174 #[inline]
175 pub fn d2t_map(&self, draft_id: u32) -> u32 {
176 (draft_id as i64 + self.d2t[draft_id as usize]) as u32
177 }
178
179 /// ENCODE (once per round, EAGLE-PLAN N3): g = fc @ concat(aux0, aux1, aux2). `aux` are the 3
180 /// trunk residual hiddens of the just-committed token (decode_step_aux / decode_step_t_aux),
181 /// in ascending-layer order. Returns the recurrent draft hidden `g` [n_embd].
182 pub fn encode(
183 &self,
184 e: &Engine,
185 aux: &[CudaSlice<f32>],
186 ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
187 assert_eq!(aux.len(), self.aux_layers.len(), "aux count != #aux layers");
188 let n = self.n_embd;
189 let mut cat = e.zeros(self.aux_layers.len() * n)?;
190 for (i, a) in aux.iter().enumerate() {
191 e.copy_into(&mut cat, i * n, a, n)?;
192 }
193 e.matmul(&self.fc, &cat, 1) // [3*n_embd] @ fc[3n_embd,n_embd] -> [n_embd]
194 }
195
196 /// One DRAFT-token forward (EAGLE-PLAN N4, T=1). `prev_tok` = the TARGET token id to predict
197 /// from (last committed or previous draft). `g` = the recurrent draft hidden (encode() output
198 /// on round entry, then the previous step's g_next). Returns (draft_logits[draft_vocab] host,
199 /// g_next dev). Mirrors the vLLM op-sequence documented at the top of this file.
200 pub fn draft_token(
201 &self,
202 e: &Engine,
203 target: &HybridModel,
204 prev_tok: u32,
205 g: &CudaSlice<f32>,
206 scratch: &mut Eagle3Scratch,
207 pos: usize,
208 ) -> Result<(Vec<f32>, CudaSlice<f32>), Box<dyn std::error::Error>> {
209 let n = self.n_embd;
210 let eps = self.eps;
211 let pos_d = e.htod_i32(&[pos as i32])?;
212
213 // e = TARGET embedding of prev_tok (EAGLE3 shares the target's token embedding).
214 // eN = input_layernorm(e); gN = hidden_norm(g); residual = PRE-norm g (norm_after_residual).
215 let e_emb = e.htod(&target.embd.gather(n, &[prev_tok]))?;
216 let mut e_norm = e.zeros(n)?;
217 e.rms_norm(
218 &e_emb,
219 self.input_layernorm.float_data(),
220 &mut e_norm,
221 n,
222 1,
223 eps,
224 )?;
225 let res = e.clone_dtod(g)?;
226 let mut g_norm = e.zeros(n)?;
227 e.rms_norm(g, self.hidden_norm.float_data(), &mut g_norm, n, 1, eps)?;
228 // cat = [eN ; gN] -> [2*n_embd] (vLLM llama_eagle3: torch.cat([embeds, hidden_states])).
229 let mut cat = e.zeros(2 * n)?;
230 e.copy_into(&mut cat, 0, &e_norm, n)?;
231 e.copy_into(&mut cat, n, &g_norm, n)?;
232
233 // attention from the 2*n_embd concat (plain Llama: no QK-norm, no output gate).
234 let attn = self.attn(e, &cat, &pos_d, scratch)?;
235 // x1 = attn + residual(g)
236 let mut x1 = e.zeros(n)?;
237 e.add(&attn, &res, &mut x1, n)?;
238 // z = post_attention_layernorm(x1)
239 let mut z = e.zeros(n)?;
240 e.rms_norm(
241 &x1,
242 self.post_attention_layernorm.float_data(),
243 &mut z,
244 n,
245 1,
246 eps,
247 )?;
248 // mlp = down @ (silu(gate@z) * (up@z))
249 let gate = e.matmul(&self.gate_proj, &z, 1)?;
250 let up = e.matmul(&self.up_proj, &z, 1)?;
251 let mut act = e.zeros(self.n_ff)?;
252 e.silu_mul(&gate, &up, &mut act, self.n_ff)?;
253 let mlp = e.matmul(&self.down_proj, &act, 1)?;
254 // g_next = mlp + x1 (final fused-add residual; this is the aux_output recurrence)
255 let mut g_next = e.zeros(n)?;
256 e.add(&mlp, &x1, &mut g_next, n)?;
257 // dl = lm_head @ norm(g_next)
258 let mut hn = e.zeros(n)?;
259 e.rms_norm(&g_next, self.norm.float_data(), &mut hn, n, 1, eps)?;
260 let logits = e.matmul(&self.lm_head, &hn, 1)?;
261 let host = e.dtoh(&logits)?;
262 Ok((host, g_next))
263 }
264
265 /// Plain Llama attention over the [2*n_embd] concat input, T=1, on the draft's own scratch KV.
266 /// q/k/v project from 2*n_embd; partial RoPE (rope_dim_count of head_dim) at the draft theta;
267 /// GQA broadcast in fa_decode; o_proj back to n_embd. No QK-norm, no output gate.
268 fn attn(
269 &self,
270 e: &Engine,
271 cat: &CudaSlice<f32>,
272 pos_d: &CudaSlice<i32>,
273 scratch: &mut Eagle3Scratch,
274 ) -> Result<CudaSlice<f32>, Box<dyn std::error::Error>> {
275 let (nh, nhkv, hd) = (self.n_head, self.n_head_kv, self.head_dim);
276 let scale = 1.0 / (hd as f32).sqrt();
277 let mut q = e.matmul(&self.q_proj, cat, 1)?; // [nh*hd]
278 let mut k = e.matmul(&self.k_proj, cat, 1)?; // [nhkv*hd]
279 let v = e.matmul(&self.v_proj, cat, 1)?; // [nhkv*hd]
280
281 // partial RoPE: rope_dim_count from resolve_rope_dim_count (= 64 of 256), draft theta.
282 e.rope_neox(
283 &mut q,
284 pos_d,
285 hd,
286 self.rope_dim_count,
287 nh,
288 1,
289 self.rope_theta,
290 1.0,
291 )?;
292 e.rope_neox(
293 &mut k,
294 pos_d,
295 hd,
296 self.rope_dim_count,
297 nhkv,
298 1,
299 self.rope_theta,
300 1.0,
301 )?;
302
303 let kv = &mut scratch.kv;
304 e.append_kv_quantized(
305 &k,
306 &v,
307 &mut kv.k,
308 &mut kv.v,
309 kv.len,
310 kv.kv_dim_k,
311 kv.kv_dim_v,
312 kv.k_tok_bytes,
313 kv.v_tok_bytes,
314 false,
315 )?;
316 kv.len += 1;
317 let t_kv = kv.len;
318 let (ktb, vtb) = (kv.k_tok_bytes, kv.v_tok_bytes);
319 let k_view = e.view_u8(&kv.k, t_kv * ktb);
320 let v_view = e.view_u8(&kv.v, t_kv * vtb);
321 let mut attn = e.zeros(nh * hd)?;
322 e.fa_decode(
323 &q, &k_view, &v_view, &mut attn, hd, nh, nhkv, t_kv, scale, ktb, vtb,
324 )?;
325 e.matmul(&self.o_proj, &attn, 1)
326 }
327}
328
329/// Tiny scratch KV for the EAGLE3 draft layer (one full-attn layer). Reset each draft round. Uses
330/// the SAME q8_0-K / q5_1-V quantized layout as the trunk KV (head_dim%32==0 holds: 256).
331pub struct Eagle3Scratch {
332 pub kv: KvLayer,
333}
334impl Eagle3Scratch {
335 pub fn new(
336 e: &Engine,
337 draft: &Eagle3Draft,
338 cap: usize,
339 ) -> Result<Self, Box<dyn std::error::Error>> {
340 let (nhkv, hd) = (draft.n_head_kv, draft.head_dim);
341 assert!(
342 hd % 32 == 0,
343 "KVQUANT requires head_dim%32==0 (EAGLE3 scratch)"
344 );
345 let kv_dim_k = hd * nhkv;
346 let kv_dim_v = hd * nhkv;
347 let (kbb, vbb) = crate::kv_blk_bytes(); // env-selected KV formats (default 34/24)
348 let k_tok_bytes = (kv_dim_k / 32) * kbb;
349 let v_tok_bytes = (kv_dim_v / 32) * vbb;
350 Ok(Eagle3Scratch {
351 kv: KvLayer {
352 k: e.alloc_u8(cap * k_tok_bytes)?,
353 v: e.alloc_u8(cap * v_tok_bytes)?,
354 kv_dim_k,
355 kv_dim_v,
356 k_tok_bytes,
357 v_tok_bytes,
358 len: 0,
359 ring: None,
360 len_d: e.htod_i32(&[0])?,
361 base_d: None,
362 },
363 })
364 }
365 pub fn reset(&mut self) {
366 self.kv.len = 0;
367 }
368}
369
370impl HybridModel {
371 /// Greedy EAGLE3 speculative decode (EAGLE-PLAN N6). Token-identical to `generate(prompt,n)`
372 /// but drafts K tokens with the separate EAGLE3 draft, then verifies them in ONE batched target
373 /// forward. Verify/accept/snapshot/rollback are REUSED from the MTP path (decode_step_t,
374 /// cache.snapshot/rollback). Returns (tokens, total_drafted, total_accepted).
375 pub fn generate_spec_eagle(
376 &self,
377 e: &Engine,
378 draft: &Eagle3Draft,
379 prompt: &[u32],
380 max_new: usize,
381 k: usize,
382 ) -> Result<(Vec<u32>, usize, usize), Box<dyn std::error::Error>> {
383 self.refuse_hyper("generate_spec_eagle")?;
384 assert!(k >= 1, "k must be >= 1");
385 assert!(!prompt.is_empty(), "prompt must be non-empty");
386 let n_vocab = self.output.out_features();
387 let n_embd = self.cfg.n_embd as usize;
388 assert_eq!(n_embd, draft.n_embd, "draft n_embd != target n_embd");
389 let aux = &draft.aux_layers;
390 let max_ctx = prompt.len() + max_new + k + 8;
391 let mut cache = Cache::new(e, &self.cfg, max_ctx)?;
392
393 // prime: feed the prompt; capture the LAST token's aux hiddens (seed for round-1 encode).
394 let mut prime_logits = Vec::new();
395 let mut prime_aux: Vec<CudaSlice<f32>> = Vec::new();
396 for &tok in prompt {
397 let (l, a) = self.decode_step_aux(e, tok, &mut cache, aux)?;
398 prime_logits = l;
399 prime_aux = a;
400 }
401
402 let mut scratch = Eagle3Scratch::new(e, draft, k + 1)?;
403 let mut out: Vec<u32> = Vec::with_capacity(max_new);
404 let mut total_drafted = 0usize;
405 let mut total_accepted = 0usize;
406
407 // EAGLE3 token/hidden alignment (vLLM `llama_eagle3.py`/`cnets.py`): the draft pairs the
408 // aux hidden of position p with the EMBEDDING of the token at position p+1 (input_ids are the
409 // target tokens shifted left by one). So drafting the token after `last_token` (at pos p)
410 // uses g = encode(aux of the token BEFORE last_token, at pos p-1) and embed(last_token).
411 // MEMRA_EAGLE_ALIGN=0 forces the un-shifted MTP-style pairing (aux & embed both = last_token)
412 // for A/B comparison; default (1) is the EAGLE shift. The prime loop already gave us the
413 // aux of the prompt's last token (= the predecessor of `last_token`), so we keep it as
414 // `prev_aux` and roll it forward by one each round.
415 let shift = std::env::var("MEMRA_EAGLE_ALIGN")
416 .ok()
417 .map(|s| s != "0")
418 .unwrap_or(true);
419 let mut last_token = argmax(&prime_logits) as u32;
420 out.push(last_token);
421 // prev_aux = aux of the token at the position whose forward predicted `last_token`
422 // (= the prompt's last token for round 1). g_aux = aux of `last_token` itself.
423 let mut prev_aux = prime_aux;
424 let (mut last_logits, mut g_aux) = self.decode_step_aux(e, last_token, &mut cache, aux)?;
425
426 while out.len() < max_new {
427 let pos = cache.pos;
428 let snap = cache.snapshot(e)?;
429
430 // --- 1. ENCODE once: g0 = fc @ concat(aux). With the EAGLE shift, the seed aux is the
431 // PREDECESSOR token's (paired with embed(last_token)); else last_token's own. ---
432 let seed_aux = if shift { &prev_aux } else { &g_aux };
433 let g0 = draft.encode(e, seed_aux)?;
434
435 // --- 2. DRAFT k tokens with the EAGLE3 draft (autoregressive, T=1 each) ---
436 scratch.reset();
437 let mut draft_toks: Vec<u32> = Vec::with_capacity(k);
438 let mut prev = last_token;
439 let mut g = g0;
440 for j in 0..k {
441 let (dl, g_next) = draft.draft_token(e, self, prev, &g, &mut scratch, pos + j)?;
442 let d_draft = argmax(&dl) as u32;
443 let d_target = draft.d2t_map(d_draft); // map draft-vocab id -> target-vocab id
444 draft_toks.push(d_target);
445 prev = d_target;
446 g = g_next;
447 }
448
449 // --- 3. VERIFY: one batched target forward over draft_toks (T=k). REUSED from MTP. ---
450 let tlogits = self.decode_step_t(e, &draft_toks, pos, &mut cache)?;
451
452 // --- 4. GREEDY ACCEPT (walk prefix, stop at first mismatch). REUSED logic. ---
453 let t_pred = |j: usize| -> u32 {
454 if j == 0 {
455 argmax(&last_logits) as u32
456 } else {
457 argmax(&tlogits[(j - 1) * n_vocab..j * n_vocab]) as u32
458 }
459 };
460 let mut n_acc = 0usize;
461 #[allow(clippy::needless_range_loop)]
462 // allow: the explicit index loop keeps the offset arithmetic visible and aligned with the device-side indexing
463 for j in 0..k {
464 if t_pred(j) == draft_toks[j] {
465 n_acc += 1;
466 } else {
467 break;
468 }
469 }
470 let bonus = t_pred(n_acc);
471 total_drafted += k;
472 total_accepted += n_acc;
473
474 // --- 5. COMMIT draft[0..n_acc] then bonus ---
475 #[allow(clippy::needless_range_loop)]
476 // allow: the explicit index loop keeps the offset arithmetic visible and aligned with the device-side indexing
477 for j in 0..n_acc {
478 if out.len() >= max_new {
479 break;
480 }
481 out.push(draft_toks[j]);
482 }
483 let bonus_emitted = out.len() < max_new;
484 if bonus_emitted {
485 out.push(bonus);
486 }
487 last_token = bonus;
488
489 // --- 6. ROLLBACK + advance to pos + n_acc + 1 committed tokens (REUSED from MTP). The
490 // next round's EAGLE seed needs TWO auxs: g_aux = aux(bonus) and prev_aux =
491 // aux(bonus's predecessor). bonus's predecessor is the last committed token BEFORE
492 // bonus = draft[n_acc-1] if n_acc>=1, else this round's `last_token` (its aux is
493 // the CURRENT g_aux). We always replay [committed-tail.. , bonus] aux-capturing so
494 // the predecessor's aux is the second-to-last column; this keeps both exact.
495 let pred_is_prev_round = n_acc == 0; // bonus's predecessor = old last_token
496 let old_g_aux = std::mem::take(&mut g_aux); // = aux(old last_token)
497 // Unified exact path (also covers full-accept n_acc==k): restore the pre-round snapshot
498 // then replay the committed prefix draft[0..n_acc] ++ [bonus] as ONE T=(n_acc+1) aux-
499 // capturing forward — single weight read, bit-identical to greedy (verify-all-columns
500 // math). Captures aux at the last column (bonus) and, when the predecessor of bonus is a
501 // replayed token (n_acc>=1), the second-to-last column.
502 cache.rollback(e, &snap, 0)?;
503 let mut replay: Vec<u32> = draft_toks[0..n_acc].to_vec();
504 replay.push(bonus);
505 let pred_col = if pred_is_prev_round {
506 None
507 } else {
508 Some(replay.len() - 2)
509 };
510 let (rl, mut a_last, a_pred) =
511 self.decode_step_t_aux2(e, &replay, pos, &mut cache, aux, pred_col)?;
512 last_logits = rl[(replay.len() - 1) * n_vocab..replay.len() * n_vocab].to_vec();
513 prev_aux = if pred_is_prev_round {
514 old_g_aux
515 } else {
516 a_pred.unwrap()
517 };
518 g_aux = std::mem::take(&mut a_last);
519 }
520 out.truncate(max_new);
521 Ok((out, total_drafted, total_accepted))
522 }
523}
524
525// ============================ draft config.json (geometry + rope) ============================
526
527struct EagleConfig {
528 hidden_size: usize,
529 n_head: usize,
530 n_head_kv: usize,
531 head_dim: usize,
532 intermediate_size: usize,
533 draft_vocab: usize,
534 /// Explicit rotary dim count (`rotary_dim`, the MiniMax-M3 spelling). `None` on every
535 /// published EAGLE3 draft today; read anyway because the trunk readers honour it and a
536 /// draft config that declares it must not be silently ignored here.
537 rotary_dim: Option<u32>,
538 /// Fraction of `head_dim` that rotates (`partial_rotary_factor`, the Qwen3.5-family
539 /// spelling; eagle3-qwen35-9b declares 0.25 both top-level and under `rope_parameters`).
540 /// `None` means the config declares no partial rotary — full rope, resolved by
541 /// `resolve_rope_dim_count`, NOT defaulted to 1.0 here so the absent/malformed arms take
542 /// the same path the GGUF and HF/safetensors readers take.
543 partial_rotary_factor: Option<f32>,
544 rope_theta: f32,
545 rms_eps: f32,
546 aux_layers: Vec<usize>,
547}
548
549impl EagleConfig {
550 /// Rotary width for the draft attention: `resolve_rope_dim_count`, the ONE derivation the
551 /// GGUF and HF/safetensors readers already share (explicit dims > fraction > full width;
552 /// malformed fractions take the full width instead of a silently odd rotation). This used
553 /// to be a third, parallel implementation — `partial_rotary_factor.unwrap_or(1.0) *
554 /// head_dim`, no `rotary_dim`, no malformed-factor refusal — which is exactly the
555 /// two-implementations-drift class that gave the HF trunk path full rope on qwen3_5*
556 /// while its GGUF twin was correct (hermes finding d3a9414b560416b5).
557 fn rope_dim_count(&self) -> usize {
558 memra_gguf::config::resolve_rope_dim_count(
559 self.rotary_dim,
560 self.partial_rotary_factor,
561 self.head_dim as u32,
562 ) as usize
563 }
564
565 fn from_json(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
566 Self::from_json_str(&std::fs::read_to_string(path)?)
567 }
568
569 fn from_json_str(txt: &str) -> Result<Self, Box<dyn std::error::Error>> {
570 // Minimal field extraction (avoid a serde dep here; the draft config.json is flat-ish).
571 let num = |key: &str| -> Option<f64> {
572 let pat = format!("\"{key}\"");
573 let i = txt.find(&pat)? + pat.len();
574 let rest = &txt[i..];
575 let c = rest.find(':')? + 1;
576 let tail = rest[c..].trim_start();
577 let end = tail.find([',', '}', '\n']).unwrap_or(tail.len());
578 tail[..end].trim().parse::<f64>().ok()
579 };
580 let aux_layers: Vec<usize> = {
581 // eagle_aux_hidden_state_layer_ids: [1, 15, 28]
582 let pat = "\"eagle_aux_hidden_state_layer_ids\"";
583 match txt.find(pat) {
584 Some(i) => {
585 let rest = &txt[i + pat.len()..];
586 let lb = rest.find('[').ok_or("no [ after aux ids")?;
587 let rb = rest.find(']').ok_or("no ] after aux ids")?;
588 rest[lb + 1..rb]
589 .split(',')
590 .filter_map(|s| s.trim().parse::<usize>().ok())
591 .collect()
592 }
593 None => vec![1, 15, 28], // fall back to the known EAGLE3-qwen35-9b layers
594 }
595 };
596 Ok(EagleConfig {
597 hidden_size: num("hidden_size").ok_or("hidden_size")? as usize,
598 n_head: num("num_attention_heads").ok_or("num_attention_heads")? as usize,
599 n_head_kv: num("num_key_value_heads").ok_or("num_key_value_heads")? as usize,
600 head_dim: num("head_dim").ok_or("head_dim")? as usize,
601 intermediate_size: num("intermediate_size").ok_or("intermediate_size")? as usize,
602 draft_vocab: num("draft_vocab_size").ok_or("draft_vocab_size")? as usize,
603 rotary_dim: num("rotary_dim").map(|v| v as u32),
604 partial_rotary_factor: num("partial_rotary_factor").map(|v| v as f32),
605 rope_theta: num("rope_theta").unwrap_or(10000.0) as f32,
606 rms_eps: num("rms_norm_eps").unwrap_or(1e-6) as f32,
607 aux_layers,
608 })
609 }
610}
611
612/// Read an i64 1-D tensor (d2t) from the draft safetensors.
613fn read_i64(m: &StModel, name: &str) -> Result<Vec<i64>, Box<dyn std::error::Error>> {
614 let (info, bytes) = m
615 .raw(name)
616 .ok_or_else(|| format!("EAGLE3 draft missing {name}"))?;
617 assert_eq!(info.dtype, "I64", "{name} dtype != I64");
618 let n = bytes.len() / 8;
619 let mut v = Vec::with_capacity(n);
620 for i in 0..n {
621 v.push(i64::from_le_bytes(
622 bytes[i * 8..i * 8 + 8].try_into().unwrap(),
623 ));
624 }
625 Ok(v)
626}
627
628/// The draft loader's rope width shares `resolve_rope_dim_count` with the GGUF and HF readers —
629/// these tests pin that it stays ONE derivation (hermes d3a9414b560416b5, the lane that fixed the
630/// trunk HF path getting n_rot=256 where its GGUF twin said 64). CPU-only: config text in, width
631/// out, no device, no checkpoint.
632///
633/// The fixture is the REAL `eagle3-qwen35-9b/config.json` — the exact checkpoint this loader
634/// serves — verbatim, not a hand-written approximation. The trunk lane's postmortem: a fixture
635/// unrepresentative of every real instance of the arch it claims to model is how the suite came
636/// to bless full rope. Variant shapes below are derived from the real text by asserted edits, so
637/// a drifted fixture fails loudly instead of testing a config that no longer exists.
638#[cfg(test)]
639mod draft_rope_width_tests {
640 use super::EagleConfig;
641 use memra_gguf::config::{HfConfig, resolve_rope_dim_count};
642
643 /// Verbatim `~/ai-ml/hf-models/eagle3-qwen35-9b/config.json` (banked shape also in the lane
644 /// receipts, darklanes research/ornith-prep-20260819/N-ROT-FIX.md): `partial_rotary_factor`
645 /// 0.25 declared BOTH top-level and under `rope_parameters` (the Ornith spelling spread),
646 /// `head_dim` 256, and — like every published qwen3_5-family config — NO `rotary_dim`.
647 const EAGLE3_QWEN35_9B_CONFIG: &str = r#"{
648 "architectures": [
649 "LlamaForCausalLMEagle3"
650 ],
651 "attention_bias": false,
652 "attention_dropout": 0.0,
653 "bos_token_id": 248040,
654 "draft_vocab_size": 32000,
655 "dtype": "bfloat16",
656 "eos_token_id": 248044,
657 "head_dim": 256,
658 "hidden_act": "silu",
659 "hidden_size": 4096,
660 "initializer_range": 0.02,
661 "intermediate_size": 12288,
662 "max_position_embeddings": 262144,
663 "mlp_bias": false,
664 "model_type": "llama",
665 "num_attention_heads": 16,
666 "num_hidden_layers": 1,
667 "num_key_value_heads": 4,
668 "pad_token_id": null,
669 "partial_rotary_factor": 0.25,
670 "pretraining_tp": 1,
671 "rms_norm_eps": 1e-06,
672 "rope_parameters": {
673 "partial_rotary_factor": 0.25,
674 "rope_theta": 10000000,
675 "rope_type": "default"
676 },
677 "tie_word_embeddings": false,
678 "transformers_version": "5.3.0",
679 "use_cache": true,
680 "vocab_size": 248320,
681 "eagle_config": {
682 "use_aux_hidden_state": true,
683 "eagle_aux_hidden_state_layer_ids": [1, 15, 28]
684 }
685}"#;
686
687 /// Edit the fixture, refusing to no-op: a variant built by a replace that matched nothing
688 /// would silently test the unmodified shape.
689 fn edited(from: &str, to: &str) -> String {
690 assert!(
691 EAGLE3_QWEN35_9B_CONFIG.contains(from),
692 "fixture drifted: {from:?} not found — the variant below would test the wrong shape"
693 );
694 EAGLE3_QWEN35_9B_CONFIG.replace(from, to)
695 }
696
697 /// Both readers of one config must extract the same two rope facts. This is the divergence
698 /// gate — the same shape as the trunk lane's `n_rot_agrees_across_the_gguf_and_hf_loader_paths`
699 /// — because the draft reader is a hand-rolled scanner and `HfConfig::parse` is the structured
700 /// parser, and nothing else forces them to agree on what a config declares.
701 fn assert_reader_parity(json: &str) -> usize {
702 let draft = EagleConfig::from_json_str(json).expect("draft reader must parse the fixture");
703 let hf = HfConfig::parse(json);
704 assert_eq!(
705 draft.rotary_dim, hf.rotary_dim,
706 "draft scanner and HfConfig::parse disagree on rotary_dim for the same config"
707 );
708 assert_eq!(
709 draft.partial_rotary_factor, hf.partial_rotary_factor,
710 "draft scanner and HfConfig::parse disagree on partial_rotary_factor for the same config"
711 );
712 let expected = resolve_rope_dim_count(
713 hf.rotary_dim,
714 hf.partial_rotary_factor,
715 hf.head_dim.expect("fixture declares head_dim"),
716 ) as usize;
717 assert_eq!(
718 draft.rope_dim_count(),
719 expected,
720 "draft rope width diverged from the shared derivation on the same facts"
721 );
722 draft.rope_dim_count()
723 }
724
725 /// The teeth: a mutation that reintroduces full-rope derivation (ignoring the factor, or
726 /// multiplying an unwrap_or(1.0) default) fails HERE, on the real checkpoint's own config,
727 /// with the corrupted band named.
728 #[test]
729 fn real_eagle3_qwen35_9b_config_derives_partial_rope_64_of_256() {
730 let cfg = EagleConfig::from_json_str(EAGLE3_QWEN35_9B_CONFIG).expect("real config parses");
731 assert_eq!(cfg.head_dim, 256);
732 assert_eq!(
733 cfg.rotary_dim, None,
734 "no published EAGLE3 draft declares rotary_dim"
735 );
736 assert_eq!(
737 cfg.partial_rotary_factor,
738 Some(0.25),
739 "the declared factor must be READ, not defaulted — unwrap_or(1.0) is the bug class"
740 );
741 assert_eq!(
742 cfg.rope_dim_count(),
743 64,
744 "eagle3-qwen35-9b rotates 64 of 256 head dims; full rope silently corrupts the \
745 pass-through band 64..256 — no shape error, fluent output, wrecked long context"
746 );
747 assert_eq!(assert_reader_parity(EAGLE3_QWEN35_9B_CONFIG), 64);
748 }
749
750 /// The Qwen3.5-122B spelling: the factor ONLY under `rope_parameters`, nothing top-level.
751 /// A rewrite of the scanner that reads only the top-level key regresses exactly here.
752 #[test]
753 fn nested_only_partial_rotary_spelling_is_still_partial_rope() {
754 let json = edited("\n \"partial_rotary_factor\": 0.25,", "");
755 let cfg = EagleConfig::from_json_str(&json).expect("nested-only config parses");
756 assert_eq!(
757 cfg.partial_rotary_factor,
758 Some(0.25),
759 "rope_parameters spelling must be read"
760 );
761 assert_eq!(cfg.rope_dim_count(), 64);
762 assert_reader_parity(&json);
763 }
764
765 /// The honest default, isolated (the trunk lane's
766 /// `qwen35_hf_without_a_partial_rotary_declaration_is_full_rope` twin): no declaration at
767 /// all means full rope, and this case must never be conflated with the partial answer.
768 #[test]
769 fn no_rope_declaration_is_full_rope() {
770 let json = edited("\n \"partial_rotary_factor\": 0.25,", "")
771 .replace("\n \"partial_rotary_factor\": 0.25,", "");
772 assert!(
773 !json.contains("partial_rotary_factor"),
774 "variant edit failed: a factor spelling survived"
775 );
776 let cfg = EagleConfig::from_json_str(&json).expect("undeclared-rope config parses");
777 assert_eq!(cfg.partial_rotary_factor, None);
778 assert_eq!(
779 cfg.rope_dim_count(),
780 256,
781 "absent declaration = every head dim rotates"
782 );
783 assert_reader_parity(&json);
784 }
785
786 /// Explicit dims beat the fraction — the shared precedence. The old draft code read ONLY the
787 /// fraction, so a draft config carrying `rotary_dim` (the MiniMax-M3 spelling, what a
788 /// converter writes once it has resolved the fraction) was silently ignored. A mutation back
789 /// to factor-only arithmetic fails here.
790 #[test]
791 fn explicit_rotary_dim_wins_over_the_fraction() {
792 let json = edited(
793 "\n \"partial_rotary_factor\": 0.25,",
794 "\n \"partial_rotary_factor\": 0.25,\n \"rotary_dim\": 32,",
795 );
796 let cfg = EagleConfig::from_json_str(&json).expect("explicit-dims config parses");
797 assert_eq!(cfg.rotary_dim, Some(32));
798 assert_eq!(
799 cfg.rope_dim_count(),
800 32,
801 "explicit rotary_dim is the more specific declaration and must win over the fraction"
802 );
803 assert_reader_parity(&json);
804 }
805
806 /// Malformed fractions refuse to truncate — same posture as the trunk readers. The OLD draft
807 /// arithmetic multiplied the raw factor: 2.0 * 256 = a 512-dim rotation over a 256-dim head
808 /// (writing past the head), and 0.0 * 256 -> max(2) = a 2-dim rotation that silently
809 /// disables rope while looking like a plausible model.
810 #[test]
811 fn malformed_factor_takes_full_width_not_a_wider_than_head_rotation() {
812 let over = EAGLE3_QWEN35_9B_CONFIG.replace(
813 "\"partial_rotary_factor\": 0.25",
814 "\"partial_rotary_factor\": 2.0",
815 );
816 let cfg = EagleConfig::from_json_str(&over).expect("factor-2.0 config parses");
817 assert_eq!(cfg.partial_rotary_factor, Some(2.0));
818 assert_eq!(
819 cfg.rope_dim_count(),
820 256,
821 "factor 2.0 must take the FULL head width (256), never 512 — the old \
822 factor*head_dim arithmetic rotated past the head allocation"
823 );
824 assert_reader_parity(&over);
825
826 let zero = EAGLE3_QWEN35_9B_CONFIG.replace(
827 "\"partial_rotary_factor\": 0.25",
828 "\"partial_rotary_factor\": 0.0",
829 );
830 let cfg = EagleConfig::from_json_str(&zero).expect("factor-0.0 config parses");
831 assert_eq!(
832 cfg.rope_dim_count(),
833 256,
834 "factor 0.0 is malformed and takes the full width, not the old max(2) stub rotation"
835 );
836 assert_reader_parity(&zero);
837 }
838}