Skip to main content

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