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`](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, but running them requires the `dflash2` build feature, which vendors
216/// the unmerged upstream PR #27342.
217#[cfg(feature = "dflash2")]
218pub type DFlashSession<'ctx, 'target_model, 'draft_model> =
219    Eagle3Session<'ctx, 'target_model, 'draft_model>;
220
221/// Owned separate-draft-model speculative session (EAGLE-3 or `DFlash`).
222///
223/// Drops the underlying speculative context when freed.
224///
225/// Both contexts are exclusively borrowed for the session lifetime. The
226/// wrapper retains no manually enforced lifetime and is neither `Send` nor
227/// `Sync`.
228///
229/// With the `dflash2` feature this same type also drives `DFlash` drafts via
230/// `new_dflash` (aliased as `DFlashSession`). The two backends share one type
231/// because the drafting protocol is identical; only construction differs.
232pub struct Eagle3Session<'ctx, 'target_model, 'draft_model> {
233    raw: NonNull<llama_cpp_sys_4::mtp_session>,
234    config: Eagle3SessionConfig,
235    target: &'ctx mut LlamaContext<'target_model>,
236    draft: &'ctx mut LlamaContext<'draft_model>,
237    pending_proposals: Vec<Option<usize>>,
238    not_send_sync: PhantomData<Rc<()>>,
239}
240
241impl<'ctx, 'target_model, 'draft_model> Eagle3Session<'ctx, 'target_model, 'draft_model> {
242    /// Construct an EAGLE-3 draft session with upstream defaults for `n_min`
243    /// and `p_min`.
244    ///
245    /// Equivalent to `new_with_config(target, draft, Eagle3SessionConfig::new(n_seq, n_draft_max))`.
246    ///
247    /// # Errors
248    ///
249    /// Returns [`Eagle3SessionError::Init`] or [`Eagle3SessionError::InvalidConfig`].
250    pub fn new(
251        target: &'ctx mut LlamaContext<'target_model>,
252        draft: &'ctx mut LlamaContext<'draft_model>,
253        n_seq: u32,
254        n_draft_max: i32,
255    ) -> Result<Self, Eagle3SessionError> {
256        Self::new_with_config(target, draft, Eagle3SessionConfig::new(n_seq, n_draft_max))
257    }
258
259    /// Construct an EAGLE-3 draft session with full speculative draft
260    /// parameters.
261    ///
262    /// `target` must be a
263    /// [`LlamaContextType::Default`](crate::context::params::LlamaContextType::Default)
264    /// context over the main model. `draft` must be a `Default` context over a
265    /// **separate EAGLE-3 draft model** trained against that target.
266    ///
267    /// # Errors
268    ///
269    /// Returns [`Eagle3SessionError::Init`] (e.g. the draft model is not a
270    /// valid EAGLE-3 model) or [`Eagle3SessionError::InvalidConfig`].
271    pub fn new_with_config(
272        target: &'ctx mut LlamaContext<'target_model>,
273        draft: &'ctx mut LlamaContext<'draft_model>,
274        config: Eagle3SessionConfig,
275    ) -> Result<Self, Eagle3SessionError> {
276        // Config is checked before the contexts so an invalid config reports
277        // `InvalidConfig` rather than an incidental context mismatch.
278        validate_config(config.n_seq, config.n_draft_max, config.n_min, config.p_min)
279            .map_err(Eagle3SessionError::InvalidConfig)?;
280        validate_contexts(target, draft, config)?;
281        // `MTP_SPEC_TYPE_*` is `c_uint` under clang/gcc and `c_int` under MSVC;
282        // `as i32` compiles on both. The allow covers the clang/gcc case.
283        #[allow(clippy::cast_possible_wrap)]
284        let spec_type = llama_cpp_sys_4::MTP_SPEC_TYPE_EAGLE3 as i32;
285        Self::new_validated(target, draft, config, spec_type)
286    }
287
288    /// Construct a **`DFlash`** draft session (upstream `draft-dflash`).
289    ///
290    /// `target` must be a
291    /// [`LlamaContextType::Default`](crate::context::params::LlamaContextType::Default)
292    /// context over the main model, and `draft` a `Default` context over a
293    /// **separate `DFlash` draft model**. `DFlash2` checkpoints — which additionally
294    /// carry the grouped dynamic convolution and candidate-selector tensors —
295    /// are detected from the checkpoint's GGUF metadata and use this same
296    /// constructor; no extra flag is required.
297    ///
298    /// Unlike [`Self::new_with_config`], the draft model is *not* required to
299    /// name three target extraction sites — `DFlash` drafts have none.
300    ///
301    /// # Errors
302    ///
303    /// Returns [`Eagle3SessionError::Init`] (e.g. the draft model is not a valid
304    /// `DFlash` model), [`Eagle3SessionError::InvalidConfig`], or
305    /// [`Eagle3SessionError::IncompatibleContexts`].
306    #[cfg(feature = "dflash2")]
307    pub fn new_dflash_with_config(
308        target: &'ctx mut LlamaContext<'target_model>,
309        draft: &'ctx mut LlamaContext<'draft_model>,
310        config: Eagle3SessionConfig,
311    ) -> Result<Self, Eagle3SessionError> {
312        validate_config(config.n_seq, config.n_draft_max, config.n_min, config.p_min)
313            .map_err(Eagle3SessionError::InvalidConfig)?;
314        validate_contexts_common(target, draft, config)?;
315        #[allow(clippy::cast_possible_wrap)]
316        let spec_type = llama_cpp_sys_4::MTP_SPEC_TYPE_DFLASH as i32;
317        Self::new_validated(target, draft, config, spec_type)
318    }
319
320    /// Construct a `DFlash` draft session with upstream-aligned defaults.
321    ///
322    /// Shorthand for [`Self::new_dflash_with_config`] with
323    /// [`Eagle3SessionConfig::new`].
324    ///
325    /// # Errors
326    ///
327    /// See [`Self::new_dflash_with_config`].
328    #[cfg(feature = "dflash2")]
329    pub fn new_dflash(
330        target: &'ctx mut LlamaContext<'target_model>,
331        draft: &'ctx mut LlamaContext<'draft_model>,
332        n_seq: u32,
333        n_draft_max: i32,
334    ) -> Result<Self, Eagle3SessionError> {
335        Self::new_dflash_with_config(target, draft, Eagle3SessionConfig::new(n_seq, n_draft_max))
336    }
337
338    /// Construct a session over already-validated contexts, selecting the
339    /// speculative backend with `spec_type` (an `MTP_SPEC_TYPE_*` value).
340    ///
341    /// EAGLE-3 and `DFlash` share this path because, from the shim's perspective,
342    /// they are the same protocol: a separate draft model behind a `Default`
343    /// context, driven by the same `mtp_session_*` calls. Only the spec type and
344    /// the draft-model validation differ.
345    fn new_validated(
346        target: &'ctx mut LlamaContext<'target_model>,
347        draft: &'ctx mut LlamaContext<'draft_model>,
348        config: Eagle3SessionConfig,
349        spec_type: i32,
350    ) -> Result<Self, Eagle3SessionError> {
351        validate_config(config.n_seq, config.n_draft_max, config.n_min, config.p_min)
352            .map_err(Eagle3SessionError::InvalidConfig)?;
353        let sequence_slots = usize::try_from(config.n_seq)
354            .map_err(|_| Eagle3SessionError::InvalidConfig("n_seq exceeds usize"))?;
355
356        let c_config = llama_cpp_sys_4::mtp_session_config {
357            n_seq: config.n_seq,
358            n_draft_max: config.n_draft_max,
359            n_min: config.n_min,
360            p_min: config.p_min,
361            spec_type,
362        };
363
364        let raw = unsafe {
365            llama_cpp_sys_4::mtp_session_new(
366                target.context.as_ptr(),
367                draft.context.as_ptr(),
368                &raw const c_config,
369            )
370        };
371        let raw = NonNull::new(raw).ok_or(Eagle3SessionError::Init)?;
372        Ok(Self {
373            raw,
374            config,
375            target,
376            draft,
377            pending_proposals: vec![None; sequence_slots],
378            not_send_sync: PhantomData,
379        })
380    }
381
382    /// Session configuration passed at construction.
383    #[must_use]
384    pub fn config(&self) -> Eagle3SessionConfig {
385        self.config
386    }
387
388    /// True when the speculative backend needs post-norm embeddings on the
389    /// target context (`llama_set_embeddings`).
390    #[must_use]
391    pub fn need_embd(&self) -> bool {
392        unsafe { llama_cpp_sys_4::mtp_session_need_embd(self.raw.as_ptr()) }
393    }
394
395    /// True when the speculative backend needs pre-norm hidden states on the
396    /// target context (`llama_set_embeddings_pre_norm`).
397    ///
398    /// Configured automatically during session init; callers normally do not
399    /// need to set it manually.
400    #[must_use]
401    pub fn need_embd_pre_norm(&self) -> bool {
402        unsafe { llama_cpp_sys_4::mtp_session_need_embd_pre_norm(self.raw.as_ptr()) }
403    }
404
405    /// Configured maximum number of tokens drafted per [`draft`](Self::draft) call.
406    #[must_use]
407    pub fn n_draft_max(&self) -> i32 {
408        self.config.n_draft_max
409    }
410
411    /// Configured minimum draft tokens (`n_min`).
412    #[must_use]
413    pub fn n_min(&self) -> i32 {
414        self.config.n_min
415    }
416
417    /// Configured draft probability floor (`p_min`).
418    #[must_use]
419    pub fn p_min(&self) -> f32 {
420        self.config.p_min
421    }
422
423    /// Configured number of sequences.
424    #[must_use]
425    pub fn n_seq(&self) -> u32 {
426        self.config.n_seq
427    }
428
429    /// Returns shared access to the target context for reading logits,
430    /// embeddings, and model metadata.
431    #[must_use]
432    pub fn target_context(&self) -> &LlamaContext<'target_model> {
433        self.target
434    }
435
436    /// Returns exclusive access to the target context while this wrapper
437    /// retains native pointer ownership.
438    #[must_use]
439    pub fn target_context_mut(&mut self) -> &mut LlamaContext<'target_model> {
440        self.target
441    }
442
443    /// Returns shared access to the draft context for metadata inspection.
444    #[must_use]
445    pub fn draft_context(&self) -> &LlamaContext<'draft_model> {
446        self.draft
447    }
448
449    /// Returns exclusive access to the draft context while this wrapper
450    /// retains native pointer ownership.
451    #[must_use]
452    pub fn draft_context_mut(&mut self) -> &mut LlamaContext<'draft_model> {
453        self.draft
454    }
455
456    /// Decodes on the target and immediately harvests the same batch into
457    /// EAGLE-3.
458    ///
459    /// # Errors
460    ///
461    /// Returns a target [`crate::DecodeError`] or native process failure.
462    pub fn decode_target_and_process(
463        &mut self,
464        batch: &mut LlamaBatch,
465    ) -> Result<(), Eagle3SessionError> {
466        self.decode_target(batch)?;
467        self.process(batch)
468    }
469
470    /// Decodes one batch on the exclusively held target context.
471    ///
472    /// Use [`Self::decode_target_and_process`] unless mechanics must run
473    /// between target decode and draft-state harvesting. This method remains
474    /// available while a draft proposal is pending because that is the target
475    /// verification phase; proposal creation, begin, and state access retain
476    /// their stricter lifecycle checks.
477    ///
478    /// # Errors
479    ///
480    /// Returns a target [`crate::DecodeError`].
481    pub fn decode_target(&mut self, batch: &mut LlamaBatch) -> Result<(), Eagle3SessionError> {
482        self.target.decode(batch)?;
483        Ok(())
484    }
485
486    /// Log speculative-decoding statistics (draft/accept counts and timings)
487    /// via llama.cpp `LOG_INF`. Install a log callback with [`crate::log_set`]
488    /// to capture output.
489    pub fn print_stats(&self) {
490        unsafe { llama_cpp_sys_4::mtp_session_print_stats(self.raw.as_ptr()) }
491    }
492
493    /// Optional: call once at the start of a fresh generation with the prompt
494    /// tokens that were just decoded into the target context.
495    ///
496    /// # Errors
497    ///
498    /// Returns [`Eagle3SessionError::BadSeqId`] if `seq_id` is out of range.
499    pub fn begin(&mut self, seq_id: i32, prompt: &[LlamaToken]) -> Result<(), Eagle3SessionError> {
500        self.check_seq(seq_id)?;
501        self.require_quiescent()?;
502        if prompt.len() > MAX_SPECULATIVE_PROMPT_TOKENS {
503            return Err(Eagle3SessionError::PromptTooLong {
504                size: prompt.len(),
505                maximum: MAX_SPECULATIVE_PROMPT_TOKENS,
506            });
507        }
508        let ok = unsafe {
509            llama_cpp_sys_4::mtp_session_begin(
510                self.raw.as_ptr(),
511                seq_id,
512                prompt.as_ptr().cast(),
513                prompt.len(),
514            )
515        };
516        if !ok {
517            return Err(Eagle3SessionError::Begin);
518        }
519        Ok(())
520    }
521
522    /// Hand the session a batch that was just decoded on the target context.
523    ///
524    /// Call this after every successful `target.decode(batch)` so upstream can
525    /// harvest the target hidden states EAGLE-3 drafts from.
526    ///
527    /// # Errors
528    ///
529    /// Returns [`Eagle3SessionError::Process`] if the underlying call fails.
530    pub fn process(&mut self, batch: &LlamaBatch) -> Result<(), Eagle3SessionError> {
531        let ok = unsafe {
532            llama_cpp_sys_4::mtp_session_process(self.raw.as_ptr(), &raw const batch.llama_batch)
533        };
534        if ok {
535            Ok(())
536        } else {
537            Err(Eagle3SessionError::Process)
538        }
539    }
540
541    /// Generate up to [`n_draft_max`](Self::n_draft_max) speculative tokens.
542    ///
543    /// `n_past` is the number of tokens already in the target KV cache for
544    /// `seq_id`. `id_last` is the last token accepted on the target (usually
545    /// the token you just sampled).
546    ///
547    /// # Errors
548    ///
549    /// Returns [`Eagle3SessionError::BadSeqId`] if `seq_id` is out of range.
550    pub fn draft(
551        &mut self,
552        seq_id: i32,
553        n_past: i32,
554        id_last: LlamaToken,
555    ) -> Result<Vec<LlamaToken>, Eagle3SessionError> {
556        self.check_seq(seq_id)?;
557        let sequence_index = self.sequence_index(seq_id)?;
558        if self.pending_proposals[sequence_index].is_some() {
559            return Err(Eagle3SessionError::ProposalPending { seq_id });
560        }
561
562        let cap = usize::try_from(self.config.n_draft_max.max(0)).unwrap_or(0);
563        let mut buf: Vec<i32> = vec![0; cap];
564        let mut out_n = i32::try_from(cap).unwrap_or(i32::MAX);
565
566        let ok = unsafe {
567            llama_cpp_sys_4::mtp_session_draft(
568                self.raw.as_ptr(),
569                seq_id,
570                n_past,
571                id_last.0,
572                buf.as_mut_ptr(),
573                &raw mut out_n,
574            )
575        };
576        if !ok {
577            return Err(Eagle3SessionError::Draft);
578        }
579
580        let n = usize::try_from(out_n.max(0)).unwrap_or(0);
581        buf.truncate(n);
582        if n > 0 {
583            self.pending_proposals[sequence_index] = Some(n);
584        }
585        Ok(buf.into_iter().map(LlamaToken).collect())
586    }
587
588    /// Inform the session how many draft tokens the target verifier accepted.
589    ///
590    /// Pass `0` when every draft was rejected.
591    ///
592    /// # Errors
593    ///
594    /// Returns [`Eagle3SessionError::BadSeqId`] if `seq_id` is out of range.
595    pub fn accept(&mut self, seq_id: i32, n_accepted: u16) -> Result<(), Eagle3SessionError> {
596        self.check_seq(seq_id)?;
597        let sequence_index = self.sequence_index(seq_id)?;
598        let proposed = self.pending_proposals[sequence_index]
599            .ok_or(Eagle3SessionError::NoPendingProposal { seq_id })?;
600        if usize::from(n_accepted) > proposed {
601            return Err(Eagle3SessionError::AcceptedTooMany {
602                accepted: n_accepted,
603                proposed,
604            });
605        }
606        let ok =
607            unsafe { llama_cpp_sys_4::mtp_session_accept(self.raw.as_ptr(), seq_id, n_accepted) };
608        if !ok {
609            return Err(Eagle3SessionError::Accept);
610        }
611        self.pending_proposals[sequence_index] = None;
612        Ok(())
613    }
614
615    /// Returns `true` when every draft proposal has been completed.
616    #[must_use]
617    pub fn is_quiescent(&self) -> bool {
618        self.pending_proposals.iter().all(Option::is_none)
619            && unsafe { llama_cpp_sys_4::mtp_session_is_quiescent(self.raw.as_ptr()) }
620    }
621
622    /// Captures versioned per-sequence speculative continuation state.
623    ///
624    /// Target and draft context bytes are separate and must be checkpointed at
625    /// the same quiescent boundary.
626    ///
627    /// # Errors
628    ///
629    /// Returns an error for an invalid sequence, pending proposal, incomplete
630    /// native support, or excessive state.
631    pub fn speculative_state(&self, seq_id: i32) -> Result<Vec<u8>, Eagle3SessionError> {
632        self.check_seq(seq_id)?;
633        self.require_quiescent()?;
634        Ok(capture_state(self.raw, seq_id)?)
635    }
636
637    /// Restores versioned per-sequence speculative continuation state.
638    ///
639    /// Restore the corresponding target and draft context bytes before calling
640    /// this method.
641    ///
642    /// # Errors
643    ///
644    /// Returns an error for an invalid sequence, pending proposal, excessive
645    /// input, or any version/configuration/state mismatch.
646    pub fn restore_speculative_state(
647        &mut self,
648        seq_id: i32,
649        state: &[u8],
650    ) -> Result<(), Eagle3SessionError> {
651        self.check_seq(seq_id)?;
652        self.require_quiescent()?;
653        restore_state(self.raw, seq_id, state)?;
654        Ok(())
655    }
656
657    /// Removes a target-context KV range.
658    ///
659    /// # Errors
660    ///
661    /// Returns a conversion error when an identifier or position exceeds
662    /// native `i32` bounds.
663    pub fn clear_target_kv_cache_seq(
664        &mut self,
665        seq_id: Option<u32>,
666        p0: Option<u32>,
667        p1: Option<u32>,
668    ) -> Result<bool, crate::context::kv_cache::KvCacheConversionError> {
669        self.target.clear_kv_cache_seq(seq_id, p0, p1)
670    }
671
672    /// Removes a draft-context KV range.
673    ///
674    /// # Errors
675    ///
676    /// Returns a conversion error when an identifier or position exceeds
677    /// native `i32` bounds.
678    pub fn clear_draft_kv_cache_seq(
679        &mut self,
680        seq_id: Option<u32>,
681        p0: Option<u32>,
682        p1: Option<u32>,
683    ) -> Result<bool, crate::context::kv_cache::KvCacheConversionError> {
684        self.draft.clear_kv_cache_seq(seq_id, p0, p1)
685    }
686
687    /// Returns the target context's exact sequence-state byte count.
688    pub fn target_state_seq_get_size_ext(&mut self, seq_id: i32, flags: u32) -> usize {
689        self.target.state_seq_get_size_ext(seq_id, flags)
690    }
691
692    /// Copies target context sequence state with exact native flags.
693    pub fn target_state_seq_get_data_ext(
694        &mut self,
695        dst: &mut [u8],
696        seq_id: i32,
697        flags: u32,
698    ) -> usize {
699        self.target.state_seq_get_data_ext(dst, seq_id, flags)
700    }
701
702    /// Restores target context sequence state with exact native flags.
703    pub fn target_state_seq_set_data_ext(&mut self, src: &[u8], seq_id: i32, flags: u32) -> usize {
704        self.target.state_seq_set_data_ext(src, seq_id, flags)
705    }
706
707    /// Returns the draft context's exact sequence-state byte count.
708    pub fn draft_state_seq_get_size_ext(&mut self, seq_id: i32, flags: u32) -> usize {
709        self.draft.state_seq_get_size_ext(seq_id, flags)
710    }
711
712    /// Copies draft context sequence state with exact native flags.
713    pub fn draft_state_seq_get_data_ext(
714        &mut self,
715        dst: &mut [u8],
716        seq_id: i32,
717        flags: u32,
718    ) -> usize {
719        self.draft.state_seq_get_data_ext(dst, seq_id, flags)
720    }
721
722    /// Restores draft context sequence state with exact native flags.
723    pub fn draft_state_seq_set_data_ext(&mut self, src: &[u8], seq_id: i32, flags: u32) -> usize {
724        self.draft.state_seq_set_data_ext(src, seq_id, flags)
725    }
726
727    fn require_quiescent(&self) -> Result<(), Eagle3SessionError> {
728        if let Some((index, _)) = self
729            .pending_proposals
730            .iter()
731            .enumerate()
732            .find(|(_, proposal)| proposal.is_some())
733        {
734            return Err(Eagle3SessionError::ProposalPending {
735                seq_id: i32::try_from(index).unwrap_or(i32::MAX),
736            });
737        }
738        if !unsafe { llama_cpp_sys_4::mtp_session_is_quiescent(self.raw.as_ptr()) } {
739            return Err(Eagle3SessionError::State(
740                SpeculativeStateError::NotQuiescent,
741            ));
742        }
743        Ok(())
744    }
745
746    fn check_seq(&self, seq_id: i32) -> Result<(), Eagle3SessionError> {
747        if seq_id < 0 || seq_id.cast_unsigned() >= self.config.n_seq {
748            return Err(Eagle3SessionError::BadSeqId {
749                seq_id,
750                n_seq: self.config.n_seq,
751            });
752        }
753        Ok(())
754    }
755
756    fn sequence_index(&self, seq_id: i32) -> Result<usize, Eagle3SessionError> {
757        self.check_seq(seq_id)?;
758        usize::try_from(seq_id)
759            .map_err(|_| Eagle3SessionError::InvalidConfig("sequence id exceeds usize"))
760    }
761}
762
763fn validate_contexts(
764    target: &LlamaContext<'_>,
765    draft: &LlamaContext<'_>,
766    config: Eagle3SessionConfig,
767) -> Result<(), Eagle3SessionError> {
768    validate_contexts_common(target, draft, config)?;
769
770    // EAGLE-3 only: the draft model must name the three target layers it
771    // extracts hidden states from. Other separate-draft backends (DFlash) carry
772    // no such sites, so this check is not part of the shared validation.
773    let target_layers = target.model.n_layer();
774    let target_architecture = target
775        .model
776        .meta_val_str("general.architecture", 64)
777        .map_err(|_| {
778            Eagle3SessionError::IncompatibleContexts(
779                "target model architecture metadata is unavailable",
780            )
781        })?;
782    if !valid_target_layer_ids(
783        draft.model.target_layer_ids(),
784        target_layers,
785        target_architecture == "gpt-oss",
786    ) {
787        return Err(Eagle3SessionError::IncompatibleContexts(
788            "draft must name exactly three supported target extraction sites",
789        ));
790    }
791    Ok(())
792}
793
794/// Validation shared by every separate-draft-model speculative backend
795/// (EAGLE-3, `DFlash`): context types, sequence capacity, and batch capacity.
796fn validate_contexts_common(
797    target: &LlamaContext<'_>,
798    draft: &LlamaContext<'_>,
799    config: Eagle3SessionConfig,
800) -> Result<(), Eagle3SessionError> {
801    if target.context_type() != LlamaContextType::Default
802        || draft.context_type() != LlamaContextType::Default
803    {
804        return Err(Eagle3SessionError::IncompatibleContexts(
805            "target and draft must both be Default contexts",
806        ));
807    }
808    if target.n_seq_max() < config.n_seq || draft.n_seq_max() != config.n_seq {
809        return Err(Eagle3SessionError::IncompatibleContexts(
810            "target sequence capacity is too small or draft capacity differs from n_seq",
811        ));
812    }
813    let required_draft = u32::try_from(config.n_draft_max)
814        .map_err(|_| Eagle3SessionError::InvalidConfig("n_draft_max exceeds u32"))?;
815    validate_context_capacities(
816        SpeculativeContextCapacity {
817            batch: target.n_batch(),
818            micro_batch: target.n_ubatch(),
819            recurrent_slots: target.n_rs_seq(),
820            recurrent_or_hybrid: target.model.is_recurrent() || target.model.is_hybrid(),
821        },
822        SpeculativeContextCapacity {
823            batch: draft.n_batch(),
824            micro_batch: draft.n_ubatch(),
825            recurrent_slots: draft.n_rs_seq(),
826            recurrent_or_hybrid: draft.model.is_recurrent() || draft.model.is_hybrid(),
827        },
828        required_draft,
829    )
830    .map_err(Eagle3SessionError::IncompatibleContexts)?;
831    Ok(())
832}
833
834fn valid_target_layer_ids(
835    layer_ids: &[i32],
836    target_layers: i32,
837    terminal_nextn_site: bool,
838) -> bool {
839    target_layers > 0
840        && layer_ids.len() == 3
841        && layer_ids.iter().all(|&layer| {
842            layer >= 0 && (layer < target_layers || (layer == target_layers && terminal_nextn_site))
843        })
844}
845
846impl Drop for Eagle3Session<'_, '_, '_> {
847    fn drop(&mut self) {
848        unsafe { llama_cpp_sys_4::mtp_session_free(self.raw.as_ptr()) }
849    }
850}
851
852impl std::fmt::Debug for Eagle3Session<'_, '_, '_> {
853    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
854        f.debug_struct("Eagle3Session")
855            .field("config", &self.config)
856            .finish_non_exhaustive()
857    }
858}
859
860#[cfg(test)]
861mod tests {
862    use super::valid_target_layer_ids;
863
864    #[test]
865    fn validates_transformer_and_terminal_nextn_sites() {
866        assert!(valid_target_layer_ids(&[1, 4, 7], 8, false));
867        assert!(!valid_target_layer_ids(&[1, 4], 8, false));
868        assert!(!valid_target_layer_ids(&[1, -1, 7], 8, false));
869        assert!(!valid_target_layer_ids(&[1, 4, 8], 8, false));
870        assert!(valid_target_layer_ids(&[1, 4, 8], 8, true));
871        assert!(!valid_target_layer_ids(&[1, 4, 9], 8, true));
872    }
873
874    /// The shim dispatches on this value, so a collision with an existing spec
875    /// type would silently select the wrong speculative backend.
876    #[cfg(feature = "dflash2")]
877    #[test]
878    fn dflash_spec_type_is_distinct() {
879        use llama_cpp_sys_4::{MTP_SPEC_TYPE_DFLASH, MTP_SPEC_TYPE_EAGLE3, MTP_SPEC_TYPE_MTP};
880        assert_ne!(MTP_SPEC_TYPE_DFLASH, MTP_SPEC_TYPE_MTP);
881        assert_ne!(MTP_SPEC_TYPE_DFLASH, MTP_SPEC_TYPE_EAGLE3);
882        // Pinned: the value is ABI, consumed by the C shim's switch.
883        assert_eq!(MTP_SPEC_TYPE_DFLASH, 2);
884    }
885
886    /// Compile-time guard that the feature-gated surface is actually reachable
887    /// under `--features dflash2` (the alias and both constructors resolve).
888    #[cfg(feature = "dflash2")]
889    #[test]
890    fn dflash_api_surface_is_exposed() {
891        let _alias: Option<super::DFlashSession<'_, '_, '_>> = None;
892        let _with_config = super::Eagle3Session::new_dflash_with_config;
893        let _shorthand = super::Eagle3Session::new_dflash;
894    }
895}