llama_cpp_4/eagle.rs
1//! Safe wrapper around the C++ EAGLE-3 draft session.
2//!
3//! [`Eagle3Session`] drives **EAGLE-3** speculative decoding
4//! (`COMMON_SPECULATIVE_TYPE_DRAFT_EAGLE3` in upstream llama.cpp). EAGLE-3
5//! pairs a target model with a small, separately-trained **EAGLE-3 draft
6//! model** that predicts the next tokens from hidden states extracted out of
7//! the target model.
8//!
9//! The draft algorithm lives in upstream's `common/speculative.cpp`
10//! (`common_speculative_impl_draft_eagle3`). This module wraps it through the
11//! same stable C shim used for MTP (`llama-cpp-sys-4/mtp_shim/`); the two
12//! techniques share an identical session lifecycle and differ only in how the
13//! draft context is built.
14//!
15//! # EAGLE-3 vs MTP
16//!
17//! | | EAGLE-3 ([`Eagle3Session`]) | MTP ([`crate::mtp::MtpSession`]) |
18//! |---|---|---|
19//! | Draft weights | a **separate** EAGLE-3 draft model | the **same** model as the target |
20//! | Draft context type | [`LlamaContextType::Default`](crate::context::params::LlamaContextType::Default) | [`LlamaContextType::Mtp`](crate::context::params::LlamaContextType::Mtp) |
21//! | Requirement | draft model must expose 3 target-extract layers | target model must have MTP heads |
22//!
23//! # Setup
24//!
25//! ```ignore
26//! use llama_cpp_4::context::params::LlamaContextParams;
27//! use llama_cpp_4::eagle::{Eagle3Session, Eagle3SessionConfig};
28//!
29//! let n_draft_max = 3;
30//!
31//! // Target: the main model, a normal (default) context.
32//! let mut target = main_model.new_context(&backend, LlamaContextParams::default())?;
33//!
34//! // Draft: a SEPARATE EAGLE-3 draft model, also a default context.
35//! let mut draft = eagle3_model.new_context(&backend, LlamaContextParams::default())?;
36//!
37//! let config = Eagle3SessionConfig::new(1, n_draft_max);
38//! let mut session = Eagle3Session::new_with_config(&mut target, &mut draft, config)?;
39//! ```
40//!
41//! # Speculative loop
42//!
43//! Identical in shape to MTP: after each decode on the **target** context call
44//! [`process`](Eagle3Session::process), then [`draft`](Eagle3Session::draft)
45//! to get candidate tokens, verify them on the target, and report how many
46//! were accepted with [`accept`](Eagle3Session::accept).
47//!
48//! ```ignore
49//! session.decode_target_and_process(&mut batch)?;
50//! let drafts = session.draft(0, n_past, last_token)?;
51//! // verify `drafts` against the target, count acceptances ...
52//! session.accept(0, n_accepted)?;
53//! ```
54//!
55//! # Hidden-state extraction
56//!
57//! EAGLE-3 needs the target model to expose internal hidden states. The
58//! session configures the required extraction on both contexts at construction
59//! time; [`need_embd`](Eagle3Session::need_embd) and
60//! [`need_embd_pre_norm`](Eagle3Session::need_embd_pre_norm) report which kind
61//! the active backend requested (rarely needed by callers).
62
63use std::marker::PhantomData;
64use std::ptr::NonNull;
65use std::rc::Rc;
66
67use crate::context::params::LlamaContextType;
68use crate::context::LlamaContext;
69use crate::llama_batch::LlamaBatch;
70use crate::speculative::MAX_SPECULATIVE_PROMPT_TOKENS;
71use crate::speculative::{
72 capture_state, restore_state, validate_config, validate_context_capacities,
73 SpeculativeContextCapacity, SpeculativeStateError,
74};
75use crate::token::LlamaToken;
76
77/// Errors raised by the EAGLE-3 draft session.
78#[derive(Debug, thiserror::Error)]
79pub enum Eagle3SessionError {
80 /// Returned when session init fails. The most common cause is that `draft`
81 /// was not built from a valid EAGLE-3 draft model (upstream expects a draft
82 /// model exposing exactly 3 target-extract layers), or that one of the
83 /// contexts is incompatible.
84 #[error("failed to create EAGLE-3 draft session — check that `draft` is a context over a valid EAGLE-3 draft model (3 extract layers) built from the same target")]
85 Init,
86
87 /// `process` returned false on the underlying speculative context.
88 #[error("EAGLE-3 process failed (see llama.cpp logs)")]
89 Process,
90
91 /// Native prompt initialization failed or raised a contained exception.
92 #[error("EAGLE-3 begin failed")]
93 Begin,
94
95 /// Native draft generation failed or raised a contained exception.
96 #[error("EAGLE-3 draft failed")]
97 Draft,
98
99 /// Native proposal acceptance failed or raised a contained exception.
100 #[error("EAGLE-3 accept failed")]
101 Accept,
102
103 /// Prompt storage exceeds the safe speculative-session bound.
104 #[error("prompt has {size} tokens, exceeding the {maximum}-token bound")]
105 PromptTooLong {
106 /// Caller-supplied prompt-token count.
107 size: usize,
108 /// Inclusive safe prompt-token bound.
109 maximum: usize,
110 },
111
112 /// The supplied contexts do not satisfy the native EAGLE-3 contract.
113 #[error("incompatible EAGLE-3 contexts: {0}")]
114 IncompatibleContexts(&'static str),
115
116 /// Caller passed a sequence id outside `[0, n_seq)`.
117 #[error("sequence id {seq_id} out of range (n_seq = {n_seq})")]
118 BadSeqId {
119 /// the offending seq id
120 seq_id: i32,
121 /// configured number of sequences
122 n_seq: u32,
123 },
124
125 /// Invalid session configuration (e.g. `n_draft_max <= 0`).
126 #[error("invalid EAGLE-3 session config: {0}")]
127 InvalidConfig(&'static str),
128
129 /// The target context failed to decode.
130 #[error("target decode failed: {0}")]
131 Decode(#[from] crate::DecodeError),
132
133 /// An operation requires all draft proposals to be completed first.
134 #[error("sequence {seq_id} still has an unaccepted draft proposal")]
135 ProposalPending {
136 /// Sequence with a pending proposal.
137 seq_id: i32,
138 },
139
140 /// `accept` was called without a preceding nonempty draft.
141 #[error("sequence {seq_id} has no draft proposal to accept")]
142 NoPendingProposal {
143 /// Sequence without a pending proposal.
144 seq_id: i32,
145 },
146
147 /// The accepted prefix exceeds the proposal length.
148 #[error("accepted {accepted} tokens from a {proposed}-token proposal")]
149 AcceptedTooMany {
150 /// Accepted prefix length.
151 accepted: u16,
152 /// Exact proposal length.
153 proposed: usize,
154 },
155
156 /// Exact speculative-state capture or restore failed.
157 #[error(transparent)]
158 State(#[from] SpeculativeStateError),
159}
160
161/// Parameters for [`Eagle3Session::new_with_config`].
162///
163/// Maps directly to upstream `common_params_speculative_draft`.
164#[derive(Debug, Clone, Copy, PartialEq)]
165pub struct Eagle3SessionConfig {
166 /// Number of concurrent sequences (usually `1`).
167 pub n_seq: u32,
168 /// Maximum tokens drafted per [`Eagle3Session::draft`] call (`n_max` upstream).
169 pub n_draft_max: i32,
170 /// Minimum draft tokens to propose (`n_min` upstream, default `0`).
171 pub n_min: i32,
172 /// Greedy probability floor; drafts below this are dropped (`p_min` upstream, default `0.0`).
173 pub p_min: f32,
174}
175
176impl Eagle3SessionConfig {
177 /// Build a config with upstream-aligned defaults for `n_min` (`0`) and
178 /// `p_min` (`0.0`).
179 #[must_use]
180 pub fn new(n_seq: u32, n_draft_max: i32) -> Self {
181 Self {
182 n_seq,
183 n_draft_max,
184 n_min: 0,
185 p_min: 0.0,
186 }
187 }
188
189 /// Set minimum draft tokens (`n_min` upstream).
190 #[must_use]
191 pub fn with_n_min(mut self, n_min: i32) -> Self {
192 self.n_min = n_min;
193 self
194 }
195
196 /// Set draft probability floor (`p_min` upstream).
197 ///
198 /// Draft tokens whose greedy probability falls below this value are dropped.
199 #[must_use]
200 pub fn with_p_min(mut self, p_min: f32) -> Self {
201 self.p_min = p_min;
202 self
203 }
204}
205
206/// A `DFlash` draft session.
207///
208/// An alias for [`Eagle3Session`], which implements both backends: from the
209/// shim's perspective `DFlash` and EAGLE-3 are the same protocol — a separate
210/// draft model behind a `Default` context — so only construction differs.
211/// Build one with [`Eagle3Session::new_dflash`] or
212/// [`Eagle3Session::new_dflash_with_config`].
213///
214/// `DFlash2` checkpoints are detected from GGUF metadata and need no extra
215/// flag or build feature — upstream merged PR #27342 in `b10658`, so the
216/// vendored llama.cpp recognises them out of the box.
217pub type DFlashSession<'ctx, 'target_model, 'draft_model> =
218 Eagle3Session<'ctx, 'target_model, 'draft_model>;
219
220/// Owned separate-draft-model speculative session (EAGLE-3 or `DFlash`).
221///
222/// Drops the underlying speculative context when freed.
223///
224/// Both contexts are exclusively borrowed for the session lifetime. The
225/// wrapper retains no manually enforced lifetime and is neither `Send` nor
226/// `Sync`.
227///
228/// This same type also drives `DFlash` drafts via `new_dflash` (aliased as
229/// `DFlashSession`). The two backends share one type because the drafting
230/// protocol is identical; only construction differs.
231pub struct Eagle3Session<'ctx, 'target_model, 'draft_model> {
232 raw: NonNull<llama_cpp_sys_4::mtp_session>,
233 config: Eagle3SessionConfig,
234 target: &'ctx mut LlamaContext<'target_model>,
235 draft: &'ctx mut LlamaContext<'draft_model>,
236 pending_proposals: Vec<Option<usize>>,
237 not_send_sync: PhantomData<Rc<()>>,
238}
239
240impl<'ctx, 'target_model, 'draft_model> Eagle3Session<'ctx, 'target_model, 'draft_model> {
241 /// Construct an EAGLE-3 draft session with upstream defaults for `n_min`
242 /// and `p_min`.
243 ///
244 /// Equivalent to `new_with_config(target, draft, Eagle3SessionConfig::new(n_seq, n_draft_max))`.
245 ///
246 /// # Errors
247 ///
248 /// Returns [`Eagle3SessionError::Init`] or [`Eagle3SessionError::InvalidConfig`].
249 pub fn new(
250 target: &'ctx mut LlamaContext<'target_model>,
251 draft: &'ctx mut LlamaContext<'draft_model>,
252 n_seq: u32,
253 n_draft_max: i32,
254 ) -> Result<Self, Eagle3SessionError> {
255 Self::new_with_config(target, draft, Eagle3SessionConfig::new(n_seq, n_draft_max))
256 }
257
258 /// Construct an EAGLE-3 draft session with full speculative draft
259 /// parameters.
260 ///
261 /// `target` must be a
262 /// [`LlamaContextType::Default`](crate::context::params::LlamaContextType::Default)
263 /// context over the main model. `draft` must be a `Default` context over a
264 /// **separate EAGLE-3 draft model** trained against that target.
265 ///
266 /// # Errors
267 ///
268 /// Returns [`Eagle3SessionError::Init`] (e.g. the draft model is not a
269 /// valid EAGLE-3 model) or [`Eagle3SessionError::InvalidConfig`].
270 pub fn new_with_config(
271 target: &'ctx mut LlamaContext<'target_model>,
272 draft: &'ctx mut LlamaContext<'draft_model>,
273 config: Eagle3SessionConfig,
274 ) -> Result<Self, Eagle3SessionError> {
275 // Config is checked before the contexts so an invalid config reports
276 // `InvalidConfig` rather than an incidental context mismatch.
277 validate_config(config.n_seq, config.n_draft_max, config.n_min, config.p_min)
278 .map_err(Eagle3SessionError::InvalidConfig)?;
279 validate_contexts(target, draft, config)?;
280 // `MTP_SPEC_TYPE_*` is `c_uint` under clang/gcc and `c_int` under MSVC;
281 // `as i32` compiles on both. The allow covers the clang/gcc case.
282 #[allow(clippy::cast_possible_wrap)]
283 let spec_type = llama_cpp_sys_4::MTP_SPEC_TYPE_EAGLE3 as i32;
284 Self::new_validated(target, draft, config, spec_type)
285 }
286
287 /// Construct a **`DFlash`** draft session (upstream `draft-dflash`).
288 ///
289 /// `target` must be a
290 /// [`LlamaContextType::Default`](crate::context::params::LlamaContextType::Default)
291 /// context over the main model, and `draft` a `Default` context over a
292 /// **separate `DFlash` draft model**. `DFlash2` checkpoints — which additionally
293 /// carry the grouped dynamic convolution and candidate-selector tensors —
294 /// are detected from the checkpoint's GGUF metadata and use this same
295 /// constructor; no extra flag is required.
296 ///
297 /// Unlike [`Self::new_with_config`], the draft model is *not* required to
298 /// name three target extraction sites — `DFlash` drafts have none.
299 ///
300 /// # Errors
301 ///
302 /// Returns [`Eagle3SessionError::Init`] (e.g. the draft model is not a valid
303 /// `DFlash` model), [`Eagle3SessionError::InvalidConfig`], or
304 /// [`Eagle3SessionError::IncompatibleContexts`].
305 pub fn new_dflash_with_config(
306 target: &'ctx mut LlamaContext<'target_model>,
307 draft: &'ctx mut LlamaContext<'draft_model>,
308 config: Eagle3SessionConfig,
309 ) -> Result<Self, Eagle3SessionError> {
310 validate_config(config.n_seq, config.n_draft_max, config.n_min, config.p_min)
311 .map_err(Eagle3SessionError::InvalidConfig)?;
312 validate_contexts_common(target, draft, config)?;
313 #[allow(clippy::cast_possible_wrap)]
314 let spec_type = llama_cpp_sys_4::MTP_SPEC_TYPE_DFLASH as i32;
315 Self::new_validated(target, draft, config, spec_type)
316 }
317
318 /// Construct a `DFlash` draft session with upstream-aligned defaults.
319 ///
320 /// Shorthand for [`Self::new_dflash_with_config`] with
321 /// [`Eagle3SessionConfig::new`].
322 ///
323 /// # Errors
324 ///
325 /// See [`Self::new_dflash_with_config`].
326 pub fn new_dflash(
327 target: &'ctx mut LlamaContext<'target_model>,
328 draft: &'ctx mut LlamaContext<'draft_model>,
329 n_seq: u32,
330 n_draft_max: i32,
331 ) -> Result<Self, Eagle3SessionError> {
332 Self::new_dflash_with_config(target, draft, Eagle3SessionConfig::new(n_seq, n_draft_max))
333 }
334
335 /// Construct a session over already-validated contexts, selecting the
336 /// speculative backend with `spec_type` (an `MTP_SPEC_TYPE_*` value).
337 ///
338 /// EAGLE-3 and `DFlash` share this path because, from the shim's perspective,
339 /// they are the same protocol: a separate draft model behind a `Default`
340 /// context, driven by the same `mtp_session_*` calls. Only the spec type and
341 /// the draft-model validation differ.
342 fn new_validated(
343 target: &'ctx mut LlamaContext<'target_model>,
344 draft: &'ctx mut LlamaContext<'draft_model>,
345 config: Eagle3SessionConfig,
346 spec_type: i32,
347 ) -> Result<Self, Eagle3SessionError> {
348 validate_config(config.n_seq, config.n_draft_max, config.n_min, config.p_min)
349 .map_err(Eagle3SessionError::InvalidConfig)?;
350 let sequence_slots = usize::try_from(config.n_seq)
351 .map_err(|_| Eagle3SessionError::InvalidConfig("n_seq exceeds usize"))?;
352
353 let c_config = llama_cpp_sys_4::mtp_session_config {
354 n_seq: config.n_seq,
355 n_draft_max: config.n_draft_max,
356 n_min: config.n_min,
357 p_min: config.p_min,
358 spec_type,
359 };
360
361 let raw = unsafe {
362 llama_cpp_sys_4::mtp_session_new(
363 target.context.as_ptr(),
364 draft.context.as_ptr(),
365 &raw const c_config,
366 )
367 };
368 let raw = NonNull::new(raw).ok_or(Eagle3SessionError::Init)?;
369 Ok(Self {
370 raw,
371 config,
372 target,
373 draft,
374 pending_proposals: vec![None; sequence_slots],
375 not_send_sync: PhantomData,
376 })
377 }
378
379 /// Session configuration passed at construction.
380 #[must_use]
381 pub fn config(&self) -> Eagle3SessionConfig {
382 self.config
383 }
384
385 /// True when the speculative backend needs post-norm embeddings on the
386 /// target context (`llama_set_embeddings`).
387 #[must_use]
388 pub fn need_embd(&self) -> bool {
389 unsafe { llama_cpp_sys_4::mtp_session_need_embd(self.raw.as_ptr()) }
390 }
391
392 /// True when the speculative backend needs pre-norm hidden states on the
393 /// target context (`llama_set_embeddings_pre_norm`).
394 ///
395 /// Configured automatically during session init; callers normally do not
396 /// need to set it manually.
397 #[must_use]
398 pub fn need_embd_pre_norm(&self) -> bool {
399 unsafe { llama_cpp_sys_4::mtp_session_need_embd_pre_norm(self.raw.as_ptr()) }
400 }
401
402 /// Configured maximum number of tokens drafted per [`draft`](Self::draft) call.
403 #[must_use]
404 pub fn n_draft_max(&self) -> i32 {
405 self.config.n_draft_max
406 }
407
408 /// Configured minimum draft tokens (`n_min`).
409 #[must_use]
410 pub fn n_min(&self) -> i32 {
411 self.config.n_min
412 }
413
414 /// Configured draft probability floor (`p_min`).
415 #[must_use]
416 pub fn p_min(&self) -> f32 {
417 self.config.p_min
418 }
419
420 /// Configured number of sequences.
421 #[must_use]
422 pub fn n_seq(&self) -> u32 {
423 self.config.n_seq
424 }
425
426 /// Returns shared access to the target context for reading logits,
427 /// embeddings, and model metadata.
428 #[must_use]
429 pub fn target_context(&self) -> &LlamaContext<'target_model> {
430 self.target
431 }
432
433 /// Returns exclusive access to the target context while this wrapper
434 /// retains native pointer ownership.
435 #[must_use]
436 pub fn target_context_mut(&mut self) -> &mut LlamaContext<'target_model> {
437 self.target
438 }
439
440 /// Returns shared access to the draft context for metadata inspection.
441 #[must_use]
442 pub fn draft_context(&self) -> &LlamaContext<'draft_model> {
443 self.draft
444 }
445
446 /// Returns exclusive access to the draft context while this wrapper
447 /// retains native pointer ownership.
448 #[must_use]
449 pub fn draft_context_mut(&mut self) -> &mut LlamaContext<'draft_model> {
450 self.draft
451 }
452
453 /// Decodes on the target and immediately harvests the same batch into
454 /// EAGLE-3.
455 ///
456 /// # Errors
457 ///
458 /// Returns a target [`crate::DecodeError`] or native process failure.
459 pub fn decode_target_and_process(
460 &mut self,
461 batch: &mut LlamaBatch,
462 ) -> Result<(), Eagle3SessionError> {
463 self.decode_target(batch)?;
464 self.process(batch)
465 }
466
467 /// Decodes one batch on the exclusively held target context.
468 ///
469 /// Use [`Self::decode_target_and_process`] unless mechanics must run
470 /// between target decode and draft-state harvesting. This method remains
471 /// available while a draft proposal is pending because that is the target
472 /// verification phase; proposal creation, begin, and state access retain
473 /// their stricter lifecycle checks.
474 ///
475 /// # Errors
476 ///
477 /// Returns a target [`crate::DecodeError`].
478 pub fn decode_target(&mut self, batch: &mut LlamaBatch) -> Result<(), Eagle3SessionError> {
479 self.target.decode(batch)?;
480 Ok(())
481 }
482
483 /// Log speculative-decoding statistics (draft/accept counts and timings)
484 /// via llama.cpp `LOG_INF`. Install a log callback with [`crate::log_set`]
485 /// to capture output.
486 pub fn print_stats(&self) {
487 unsafe { llama_cpp_sys_4::mtp_session_print_stats(self.raw.as_ptr()) }
488 }
489
490 /// Optional: call once at the start of a fresh generation with the prompt
491 /// tokens that were just decoded into the target context.
492 ///
493 /// # Errors
494 ///
495 /// Returns [`Eagle3SessionError::BadSeqId`] if `seq_id` is out of range.
496 pub fn begin(&mut self, seq_id: i32, prompt: &[LlamaToken]) -> Result<(), Eagle3SessionError> {
497 self.check_seq(seq_id)?;
498 self.require_quiescent()?;
499 if prompt.len() > MAX_SPECULATIVE_PROMPT_TOKENS {
500 return Err(Eagle3SessionError::PromptTooLong {
501 size: prompt.len(),
502 maximum: MAX_SPECULATIVE_PROMPT_TOKENS,
503 });
504 }
505 let ok = unsafe {
506 llama_cpp_sys_4::mtp_session_begin(
507 self.raw.as_ptr(),
508 seq_id,
509 prompt.as_ptr().cast(),
510 prompt.len(),
511 )
512 };
513 if !ok {
514 return Err(Eagle3SessionError::Begin);
515 }
516 Ok(())
517 }
518
519 /// Hand the session a batch that was just decoded on the target context.
520 ///
521 /// Call this after every successful `target.decode(batch)` so upstream can
522 /// harvest the target hidden states EAGLE-3 drafts from.
523 ///
524 /// # Errors
525 ///
526 /// Returns [`Eagle3SessionError::Process`] if the underlying call fails.
527 pub fn process(&mut self, batch: &LlamaBatch) -> Result<(), Eagle3SessionError> {
528 let ok = unsafe {
529 llama_cpp_sys_4::mtp_session_process(self.raw.as_ptr(), &raw const batch.llama_batch)
530 };
531 if ok {
532 Ok(())
533 } else {
534 Err(Eagle3SessionError::Process)
535 }
536 }
537
538 /// Generate up to [`n_draft_max`](Self::n_draft_max) speculative tokens.
539 ///
540 /// `n_past` is the number of tokens already in the target KV cache for
541 /// `seq_id`. `id_last` is the last token accepted on the target (usually
542 /// the token you just sampled).
543 ///
544 /// # Errors
545 ///
546 /// Returns [`Eagle3SessionError::BadSeqId`] if `seq_id` is out of range.
547 pub fn draft(
548 &mut self,
549 seq_id: i32,
550 n_past: i32,
551 id_last: LlamaToken,
552 ) -> Result<Vec<LlamaToken>, Eagle3SessionError> {
553 self.check_seq(seq_id)?;
554 let sequence_index = self.sequence_index(seq_id)?;
555 if self.pending_proposals[sequence_index].is_some() {
556 return Err(Eagle3SessionError::ProposalPending { seq_id });
557 }
558
559 let cap = usize::try_from(self.config.n_draft_max.max(0)).unwrap_or(0);
560 let mut buf: Vec<i32> = vec![0; cap];
561 let mut out_n = i32::try_from(cap).unwrap_or(i32::MAX);
562
563 let ok = unsafe {
564 llama_cpp_sys_4::mtp_session_draft(
565 self.raw.as_ptr(),
566 seq_id,
567 n_past,
568 id_last.0,
569 buf.as_mut_ptr(),
570 &raw mut out_n,
571 )
572 };
573 if !ok {
574 return Err(Eagle3SessionError::Draft);
575 }
576
577 let n = usize::try_from(out_n.max(0)).unwrap_or(0);
578 buf.truncate(n);
579 if n > 0 {
580 self.pending_proposals[sequence_index] = Some(n);
581 }
582 Ok(buf.into_iter().map(LlamaToken).collect())
583 }
584
585 /// Inform the session how many draft tokens the target verifier accepted.
586 ///
587 /// Pass `0` when every draft was rejected.
588 ///
589 /// # Errors
590 ///
591 /// Returns [`Eagle3SessionError::BadSeqId`] if `seq_id` is out of range.
592 pub fn accept(&mut self, seq_id: i32, n_accepted: u16) -> Result<(), Eagle3SessionError> {
593 self.check_seq(seq_id)?;
594 let sequence_index = self.sequence_index(seq_id)?;
595 let proposed = self.pending_proposals[sequence_index]
596 .ok_or(Eagle3SessionError::NoPendingProposal { seq_id })?;
597 if usize::from(n_accepted) > proposed {
598 return Err(Eagle3SessionError::AcceptedTooMany {
599 accepted: n_accepted,
600 proposed,
601 });
602 }
603 let ok =
604 unsafe { llama_cpp_sys_4::mtp_session_accept(self.raw.as_ptr(), seq_id, n_accepted) };
605 if !ok {
606 return Err(Eagle3SessionError::Accept);
607 }
608 self.pending_proposals[sequence_index] = None;
609 Ok(())
610 }
611
612 /// Returns `true` when every draft proposal has been completed.
613 #[must_use]
614 pub fn is_quiescent(&self) -> bool {
615 self.pending_proposals.iter().all(Option::is_none)
616 && unsafe { llama_cpp_sys_4::mtp_session_is_quiescent(self.raw.as_ptr()) }
617 }
618
619 /// Captures versioned per-sequence speculative continuation state.
620 ///
621 /// Target and draft context bytes are separate and must be checkpointed at
622 /// the same quiescent boundary.
623 ///
624 /// # Errors
625 ///
626 /// Returns an error for an invalid sequence, pending proposal, incomplete
627 /// native support, or excessive state.
628 pub fn speculative_state(&self, seq_id: i32) -> Result<Vec<u8>, Eagle3SessionError> {
629 self.check_seq(seq_id)?;
630 self.require_quiescent()?;
631 Ok(capture_state(self.raw, seq_id)?)
632 }
633
634 /// Restores versioned per-sequence speculative continuation state.
635 ///
636 /// Restore the corresponding target and draft context bytes before calling
637 /// this method.
638 ///
639 /// # Errors
640 ///
641 /// Returns an error for an invalid sequence, pending proposal, excessive
642 /// input, or any version/configuration/state mismatch.
643 pub fn restore_speculative_state(
644 &mut self,
645 seq_id: i32,
646 state: &[u8],
647 ) -> Result<(), Eagle3SessionError> {
648 self.check_seq(seq_id)?;
649 self.require_quiescent()?;
650 restore_state(self.raw, seq_id, state)?;
651 Ok(())
652 }
653
654 /// Removes a target-context KV range.
655 ///
656 /// # Errors
657 ///
658 /// Returns a conversion error when an identifier or position exceeds
659 /// native `i32` bounds.
660 pub fn clear_target_kv_cache_seq(
661 &mut self,
662 seq_id: Option<u32>,
663 p0: Option<u32>,
664 p1: Option<u32>,
665 ) -> Result<bool, crate::context::kv_cache::KvCacheConversionError> {
666 self.target.clear_kv_cache_seq(seq_id, p0, p1)
667 }
668
669 /// Removes a draft-context KV range.
670 ///
671 /// # Errors
672 ///
673 /// Returns a conversion error when an identifier or position exceeds
674 /// native `i32` bounds.
675 pub fn clear_draft_kv_cache_seq(
676 &mut self,
677 seq_id: Option<u32>,
678 p0: Option<u32>,
679 p1: Option<u32>,
680 ) -> Result<bool, crate::context::kv_cache::KvCacheConversionError> {
681 self.draft.clear_kv_cache_seq(seq_id, p0, p1)
682 }
683
684 /// Returns the target context's exact sequence-state byte count.
685 pub fn target_state_seq_get_size_ext(&mut self, seq_id: i32, flags: u32) -> usize {
686 self.target.state_seq_get_size_ext(seq_id, flags)
687 }
688
689 /// Copies target context sequence state with exact native flags.
690 pub fn target_state_seq_get_data_ext(
691 &mut self,
692 dst: &mut [u8],
693 seq_id: i32,
694 flags: u32,
695 ) -> usize {
696 self.target.state_seq_get_data_ext(dst, seq_id, flags)
697 }
698
699 /// Restores target context sequence state with exact native flags.
700 pub fn target_state_seq_set_data_ext(&mut self, src: &[u8], seq_id: i32, flags: u32) -> usize {
701 self.target.state_seq_set_data_ext(src, seq_id, flags)
702 }
703
704 /// Returns the draft context's exact sequence-state byte count.
705 pub fn draft_state_seq_get_size_ext(&mut self, seq_id: i32, flags: u32) -> usize {
706 self.draft.state_seq_get_size_ext(seq_id, flags)
707 }
708
709 /// Copies draft context sequence state with exact native flags.
710 pub fn draft_state_seq_get_data_ext(
711 &mut self,
712 dst: &mut [u8],
713 seq_id: i32,
714 flags: u32,
715 ) -> usize {
716 self.draft.state_seq_get_data_ext(dst, seq_id, flags)
717 }
718
719 /// Restores draft context sequence state with exact native flags.
720 pub fn draft_state_seq_set_data_ext(&mut self, src: &[u8], seq_id: i32, flags: u32) -> usize {
721 self.draft.state_seq_set_data_ext(src, seq_id, flags)
722 }
723
724 fn require_quiescent(&self) -> Result<(), Eagle3SessionError> {
725 if let Some((index, _)) = self
726 .pending_proposals
727 .iter()
728 .enumerate()
729 .find(|(_, proposal)| proposal.is_some())
730 {
731 return Err(Eagle3SessionError::ProposalPending {
732 seq_id: i32::try_from(index).unwrap_or(i32::MAX),
733 });
734 }
735 if !unsafe { llama_cpp_sys_4::mtp_session_is_quiescent(self.raw.as_ptr()) } {
736 return Err(Eagle3SessionError::State(
737 SpeculativeStateError::NotQuiescent,
738 ));
739 }
740 Ok(())
741 }
742
743 fn check_seq(&self, seq_id: i32) -> Result<(), Eagle3SessionError> {
744 if seq_id < 0 || seq_id.cast_unsigned() >= self.config.n_seq {
745 return Err(Eagle3SessionError::BadSeqId {
746 seq_id,
747 n_seq: self.config.n_seq,
748 });
749 }
750 Ok(())
751 }
752
753 fn sequence_index(&self, seq_id: i32) -> Result<usize, Eagle3SessionError> {
754 self.check_seq(seq_id)?;
755 usize::try_from(seq_id)
756 .map_err(|_| Eagle3SessionError::InvalidConfig("sequence id exceeds usize"))
757 }
758}
759
760fn validate_contexts(
761 target: &LlamaContext<'_>,
762 draft: &LlamaContext<'_>,
763 config: Eagle3SessionConfig,
764) -> Result<(), Eagle3SessionError> {
765 validate_contexts_common(target, draft, config)?;
766
767 // EAGLE-3 only: the draft model must name the three target layers it
768 // extracts hidden states from. Other separate-draft backends (DFlash) carry
769 // no such sites, so this check is not part of the shared validation.
770 let target_layers = target.model.n_layer();
771 let target_architecture = target
772 .model
773 .meta_val_str("general.architecture", 64)
774 .map_err(|_| {
775 Eagle3SessionError::IncompatibleContexts(
776 "target model architecture metadata is unavailable",
777 )
778 })?;
779 if !valid_target_layer_ids(
780 draft.model.target_layer_ids(),
781 target_layers,
782 target_architecture == "gpt-oss",
783 ) {
784 return Err(Eagle3SessionError::IncompatibleContexts(
785 "draft must name exactly three supported target extraction sites",
786 ));
787 }
788 Ok(())
789}
790
791/// Validation shared by every separate-draft-model speculative backend
792/// (EAGLE-3, `DFlash`): context types, sequence capacity, and batch capacity.
793fn validate_contexts_common(
794 target: &LlamaContext<'_>,
795 draft: &LlamaContext<'_>,
796 config: Eagle3SessionConfig,
797) -> Result<(), Eagle3SessionError> {
798 if target.context_type() != LlamaContextType::Default
799 || draft.context_type() != LlamaContextType::Default
800 {
801 return Err(Eagle3SessionError::IncompatibleContexts(
802 "target and draft must both be Default contexts",
803 ));
804 }
805 if target.n_seq_max() < config.n_seq || draft.n_seq_max() != config.n_seq {
806 return Err(Eagle3SessionError::IncompatibleContexts(
807 "target sequence capacity is too small or draft capacity differs from n_seq",
808 ));
809 }
810 let required_draft = u32::try_from(config.n_draft_max)
811 .map_err(|_| Eagle3SessionError::InvalidConfig("n_draft_max exceeds u32"))?;
812 validate_context_capacities(
813 SpeculativeContextCapacity {
814 batch: target.n_batch(),
815 micro_batch: target.n_ubatch(),
816 recurrent_slots: target.n_rs_seq(),
817 recurrent_or_hybrid: target.model.is_recurrent() || target.model.is_hybrid(),
818 },
819 SpeculativeContextCapacity {
820 batch: draft.n_batch(),
821 micro_batch: draft.n_ubatch(),
822 recurrent_slots: draft.n_rs_seq(),
823 recurrent_or_hybrid: draft.model.is_recurrent() || draft.model.is_hybrid(),
824 },
825 required_draft,
826 )
827 .map_err(Eagle3SessionError::IncompatibleContexts)?;
828 Ok(())
829}
830
831fn valid_target_layer_ids(
832 layer_ids: &[i32],
833 target_layers: i32,
834 terminal_nextn_site: bool,
835) -> bool {
836 target_layers > 0
837 && layer_ids.len() == 3
838 && layer_ids.iter().all(|&layer| {
839 layer >= 0 && (layer < target_layers || (layer == target_layers && terminal_nextn_site))
840 })
841}
842
843impl Drop for Eagle3Session<'_, '_, '_> {
844 fn drop(&mut self) {
845 unsafe { llama_cpp_sys_4::mtp_session_free(self.raw.as_ptr()) }
846 }
847}
848
849impl std::fmt::Debug for Eagle3Session<'_, '_, '_> {
850 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
851 f.debug_struct("Eagle3Session")
852 .field("config", &self.config)
853 .finish_non_exhaustive()
854 }
855}
856
857#[cfg(test)]
858mod tests {
859 use super::valid_target_layer_ids;
860
861 #[test]
862 fn validates_transformer_and_terminal_nextn_sites() {
863 assert!(valid_target_layer_ids(&[1, 4, 7], 8, false));
864 assert!(!valid_target_layer_ids(&[1, 4], 8, false));
865 assert!(!valid_target_layer_ids(&[1, -1, 7], 8, false));
866 assert!(!valid_target_layer_ids(&[1, 4, 8], 8, false));
867 assert!(valid_target_layer_ids(&[1, 4, 8], 8, true));
868 assert!(!valid_target_layer_ids(&[1, 4, 9], 8, true));
869 }
870
871 /// The shim dispatches on this value, so a collision with an existing spec
872 /// type would silently select the wrong speculative backend.
873 #[test]
874 fn dflash_spec_type_is_distinct() {
875 use llama_cpp_sys_4::{MTP_SPEC_TYPE_DFLASH, MTP_SPEC_TYPE_EAGLE3, MTP_SPEC_TYPE_MTP};
876 assert_ne!(MTP_SPEC_TYPE_DFLASH, MTP_SPEC_TYPE_MTP);
877 assert_ne!(MTP_SPEC_TYPE_DFLASH, MTP_SPEC_TYPE_EAGLE3);
878 // Pinned: the value is ABI, consumed by the C shim's switch.
879 assert_eq!(MTP_SPEC_TYPE_DFLASH, 2);
880 }
881
882 /// Compile-time guard that the `DFlash` surface stays reachable (the alias
883 /// and both constructors resolve).
884 #[test]
885 fn dflash_api_surface_is_exposed() {
886 // Naming each item is the guard: this stops compiling if the alias or
887 // either constructor goes missing.
888 let alias: Option<super::DFlashSession<'_, '_, '_>> = None;
889 assert!(alias.is_none());
890 let _ = super::Eagle3Session::new_dflash_with_config;
891 let _ = super::Eagle3Session::new_dflash;
892 }
893}