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 let raw = unsafe {
367 llama_cpp_sys_4::mtp_session_new(
368 target.context.as_ptr(),
369 draft.context.as_ptr(),
370 &raw const c_config,
371 )
372 };
373 let raw = NonNull::new(raw).ok_or(Eagle3SessionError::Init)?;
374 Ok(Self {
375 raw,
376 config,
377 target,
378 draft,
379 pending_proposals: vec![None; sequence_slots],
380 not_send_sync: PhantomData,
381 })
382 }
383
384 /// Session configuration passed at construction.
385 #[must_use]
386 pub fn config(&self) -> Eagle3SessionConfig {
387 self.config
388 }
389
390 /// True when the speculative backend needs post-norm embeddings on the
391 /// target context (`llama_set_embeddings`).
392 #[must_use]
393 pub fn need_embd(&self) -> bool {
394 unsafe { llama_cpp_sys_4::mtp_session_need_embd(self.raw.as_ptr()) }
395 }
396
397 /// True when the speculative backend needs pre-norm hidden states on the
398 /// target context (`llama_set_embeddings_pre_norm`).
399 ///
400 /// Configured automatically during session init; callers normally do not
401 /// need to set it manually.
402 #[must_use]
403 pub fn need_embd_pre_norm(&self) -> bool {
404 unsafe { llama_cpp_sys_4::mtp_session_need_embd_pre_norm(self.raw.as_ptr()) }
405 }
406
407 /// Configured maximum number of tokens drafted per [`draft`](Self::draft) call.
408 #[must_use]
409 pub fn n_draft_max(&self) -> i32 {
410 self.config.n_draft_max
411 }
412
413 /// Configured minimum draft tokens (`n_min`).
414 #[must_use]
415 pub fn n_min(&self) -> i32 {
416 self.config.n_min
417 }
418
419 /// Configured draft probability floor (`p_min`).
420 #[must_use]
421 pub fn p_min(&self) -> f32 {
422 self.config.p_min
423 }
424
425 /// Configured number of sequences.
426 #[must_use]
427 pub fn n_seq(&self) -> u32 {
428 self.config.n_seq
429 }
430
431 /// Returns shared access to the target context for reading logits,
432 /// embeddings, and model metadata.
433 #[must_use]
434 pub fn target_context(&self) -> &LlamaContext<'target_model> {
435 self.target
436 }
437
438 /// Returns exclusive access to the target context while this wrapper
439 /// retains native pointer ownership.
440 #[must_use]
441 pub fn target_context_mut(&mut self) -> &mut LlamaContext<'target_model> {
442 self.target
443 }
444
445 /// Returns shared access to the draft context for metadata inspection.
446 #[must_use]
447 pub fn draft_context(&self) -> &LlamaContext<'draft_model> {
448 self.draft
449 }
450
451 /// Returns exclusive access to the draft context while this wrapper
452 /// retains native pointer ownership.
453 #[must_use]
454 pub fn draft_context_mut(&mut self) -> &mut LlamaContext<'draft_model> {
455 self.draft
456 }
457
458 /// Decodes on the target and immediately harvests the same batch into
459 /// EAGLE-3.
460 ///
461 /// # Errors
462 ///
463 /// Returns a target [`crate::DecodeError`] or native process failure.
464 pub fn decode_target_and_process(
465 &mut self,
466 batch: &mut LlamaBatch,
467 ) -> Result<(), Eagle3SessionError> {
468 self.decode_target(batch)?;
469 self.process(batch)
470 }
471
472 /// Decodes one batch on the exclusively held target context.
473 ///
474 /// Use [`Self::decode_target_and_process`] unless mechanics must run
475 /// between target decode and draft-state harvesting. This method remains
476 /// available while a draft proposal is pending because that is the target
477 /// verification phase; proposal creation, begin, and state access retain
478 /// their stricter lifecycle checks.
479 ///
480 /// # Errors
481 ///
482 /// Returns a target [`crate::DecodeError`].
483 pub fn decode_target(&mut self, batch: &mut LlamaBatch) -> Result<(), Eagle3SessionError> {
484 self.target.decode(batch)?;
485 Ok(())
486 }
487
488 /// Log speculative-decoding statistics (draft/accept counts and timings)
489 /// via llama.cpp `LOG_INF`. Install a log callback with [`crate::log_set`]
490 /// to capture output.
491 pub fn print_stats(&self) {
492 unsafe { llama_cpp_sys_4::mtp_session_print_stats(self.raw.as_ptr()) }
493 }
494
495 /// Optional: call once at the start of a fresh generation with the prompt
496 /// tokens that were just decoded into the target context.
497 ///
498 /// # Errors
499 ///
500 /// Returns [`Eagle3SessionError::BadSeqId`] if `seq_id` is out of range.
501 pub fn begin(&mut self, seq_id: i32, prompt: &[LlamaToken]) -> Result<(), Eagle3SessionError> {
502 self.check_seq(seq_id)?;
503 self.require_quiescent()?;
504 if prompt.len() > MAX_SPECULATIVE_PROMPT_TOKENS {
505 return Err(Eagle3SessionError::PromptTooLong {
506 size: prompt.len(),
507 maximum: MAX_SPECULATIVE_PROMPT_TOKENS,
508 });
509 }
510 let ok = unsafe {
511 llama_cpp_sys_4::mtp_session_begin(
512 self.raw.as_ptr(),
513 seq_id,
514 prompt.as_ptr().cast(),
515 prompt.len(),
516 )
517 };
518 if !ok {
519 return Err(Eagle3SessionError::Begin);
520 }
521 Ok(())
522 }
523
524 /// Hand the session a batch that was just decoded on the target context.
525 ///
526 /// Call this after every successful `target.decode(batch)` so upstream can
527 /// harvest the target hidden states EAGLE-3 drafts from.
528 ///
529 /// # Errors
530 ///
531 /// Returns [`Eagle3SessionError::Process`] if the underlying call fails.
532 pub fn process(&mut self, batch: &LlamaBatch) -> Result<(), Eagle3SessionError> {
533 let ok = unsafe {
534 llama_cpp_sys_4::mtp_session_process(self.raw.as_ptr(), &raw const batch.llama_batch)
535 };
536 if ok {
537 Ok(())
538 } else {
539 Err(Eagle3SessionError::Process)
540 }
541 }
542
543 /// Generate up to [`n_draft_max`](Self::n_draft_max) speculative tokens.
544 ///
545 /// `n_past` is the number of tokens already in the target KV cache for
546 /// `seq_id`. `id_last` is the last token accepted on the target (usually
547 /// the token you just sampled).
548 ///
549 /// # Errors
550 ///
551 /// Returns [`Eagle3SessionError::BadSeqId`] if `seq_id` is out of range.
552 pub fn draft(
553 &mut self,
554 seq_id: i32,
555 n_past: i32,
556 id_last: LlamaToken,
557 ) -> Result<Vec<LlamaToken>, Eagle3SessionError> {
558 self.check_seq(seq_id)?;
559 let sequence_index = self.sequence_index(seq_id)?;
560 if self.pending_proposals[sequence_index].is_some() {
561 return Err(Eagle3SessionError::ProposalPending { seq_id });
562 }
563
564 let cap = usize::try_from(self.config.n_draft_max.max(0)).unwrap_or(0);
565 let mut buf: Vec<i32> = vec![0; cap];
566 let mut out_n = i32::try_from(cap).unwrap_or(i32::MAX);
567
568 let ok = unsafe {
569 llama_cpp_sys_4::mtp_session_draft(
570 self.raw.as_ptr(),
571 seq_id,
572 n_past,
573 id_last.0,
574 buf.as_mut_ptr(),
575 &raw mut out_n,
576 )
577 };
578 if !ok {
579 return Err(Eagle3SessionError::Draft);
580 }
581
582 let n = usize::try_from(out_n.max(0)).unwrap_or(0);
583 buf.truncate(n);
584 if n > 0 {
585 self.pending_proposals[sequence_index] = Some(n);
586 }
587 Ok(buf.into_iter().map(LlamaToken).collect())
588 }
589
590 /// Inform the session how many draft tokens the target verifier accepted.
591 ///
592 /// Pass `0` when every draft was rejected.
593 ///
594 /// # Errors
595 ///
596 /// Returns [`Eagle3SessionError::BadSeqId`] if `seq_id` is out of range.
597 pub fn accept(&mut self, seq_id: i32, n_accepted: u16) -> Result<(), Eagle3SessionError> {
598 self.check_seq(seq_id)?;
599 let sequence_index = self.sequence_index(seq_id)?;
600 let proposed = self.pending_proposals[sequence_index]
601 .ok_or(Eagle3SessionError::NoPendingProposal { seq_id })?;
602 if usize::from(n_accepted) > proposed {
603 return Err(Eagle3SessionError::AcceptedTooMany {
604 accepted: n_accepted,
605 proposed,
606 });
607 }
608 let ok =
609 unsafe { llama_cpp_sys_4::mtp_session_accept(self.raw.as_ptr(), seq_id, n_accepted) };
610 if !ok {
611 return Err(Eagle3SessionError::Accept);
612 }
613 self.pending_proposals[sequence_index] = None;
614 Ok(())
615 }
616
617 /// Returns `true` when every draft proposal has been completed.
618 #[must_use]
619 pub fn is_quiescent(&self) -> bool {
620 self.pending_proposals.iter().all(Option::is_none)
621 && unsafe { llama_cpp_sys_4::mtp_session_is_quiescent(self.raw.as_ptr()) }
622 }
623
624 /// Captures versioned per-sequence speculative continuation state.
625 ///
626 /// Target and draft context bytes are separate and must be checkpointed at
627 /// the same quiescent boundary.
628 ///
629 /// # Errors
630 ///
631 /// Returns an error for an invalid sequence, pending proposal, incomplete
632 /// native support, or excessive state.
633 pub fn speculative_state(&self, seq_id: i32) -> Result<Vec<u8>, Eagle3SessionError> {
634 self.check_seq(seq_id)?;
635 self.require_quiescent()?;
636 Ok(capture_state(self.raw, seq_id)?)
637 }
638
639 /// Restores versioned per-sequence speculative continuation state.
640 ///
641 /// Restore the corresponding target and draft context bytes before calling
642 /// this method.
643 ///
644 /// # Errors
645 ///
646 /// Returns an error for an invalid sequence, pending proposal, excessive
647 /// input, or any version/configuration/state mismatch.
648 pub fn restore_speculative_state(
649 &mut self,
650 seq_id: i32,
651 state: &[u8],
652 ) -> Result<(), Eagle3SessionError> {
653 self.check_seq(seq_id)?;
654 self.require_quiescent()?;
655 restore_state(self.raw, seq_id, state)?;
656 Ok(())
657 }
658
659 /// Removes a target-context KV range.
660 ///
661 /// # Errors
662 ///
663 /// Returns a conversion error when an identifier or position exceeds
664 /// native `i32` bounds.
665 pub fn clear_target_kv_cache_seq(
666 &mut self,
667 seq_id: Option<u32>,
668 p0: Option<u32>,
669 p1: Option<u32>,
670 ) -> Result<bool, crate::context::kv_cache::KvCacheConversionError> {
671 self.target.clear_kv_cache_seq(seq_id, p0, p1)
672 }
673
674 /// Removes a draft-context KV range.
675 ///
676 /// # Errors
677 ///
678 /// Returns a conversion error when an identifier or position exceeds
679 /// native `i32` bounds.
680 pub fn clear_draft_kv_cache_seq(
681 &mut self,
682 seq_id: Option<u32>,
683 p0: Option<u32>,
684 p1: Option<u32>,
685 ) -> Result<bool, crate::context::kv_cache::KvCacheConversionError> {
686 self.draft.clear_kv_cache_seq(seq_id, p0, p1)
687 }
688
689 /// Returns the target context's exact sequence-state byte count.
690 pub fn target_state_seq_get_size_ext(&mut self, seq_id: i32, flags: u32) -> usize {
691 self.target.state_seq_get_size_ext(seq_id, flags)
692 }
693
694 /// Copies target context sequence state with exact native flags.
695 pub fn target_state_seq_get_data_ext(
696 &mut self,
697 dst: &mut [u8],
698 seq_id: i32,
699 flags: u32,
700 ) -> usize {
701 self.target.state_seq_get_data_ext(dst, seq_id, flags)
702 }
703
704 /// Restores target context sequence state with exact native flags.
705 pub fn target_state_seq_set_data_ext(&mut self, src: &[u8], seq_id: i32, flags: u32) -> usize {
706 self.target.state_seq_set_data_ext(src, seq_id, flags)
707 }
708
709 /// Returns the draft context's exact sequence-state byte count.
710 pub fn draft_state_seq_get_size_ext(&mut self, seq_id: i32, flags: u32) -> usize {
711 self.draft.state_seq_get_size_ext(seq_id, flags)
712 }
713
714 /// Copies draft context sequence state with exact native flags.
715 pub fn draft_state_seq_get_data_ext(
716 &mut self,
717 dst: &mut [u8],
718 seq_id: i32,
719 flags: u32,
720 ) -> usize {
721 self.draft.state_seq_get_data_ext(dst, seq_id, flags)
722 }
723
724 /// Restores draft context sequence state with exact native flags.
725 pub fn draft_state_seq_set_data_ext(&mut self, src: &[u8], seq_id: i32, flags: u32) -> usize {
726 self.draft.state_seq_set_data_ext(src, seq_id, flags)
727 }
728
729 fn require_quiescent(&self) -> Result<(), Eagle3SessionError> {
730 if let Some((index, _)) = self
731 .pending_proposals
732 .iter()
733 .enumerate()
734 .find(|(_, proposal)| proposal.is_some())
735 {
736 return Err(Eagle3SessionError::ProposalPending {
737 seq_id: i32::try_from(index).unwrap_or(i32::MAX),
738 });
739 }
740 if !unsafe { llama_cpp_sys_4::mtp_session_is_quiescent(self.raw.as_ptr()) } {
741 return Err(Eagle3SessionError::State(
742 SpeculativeStateError::NotQuiescent,
743 ));
744 }
745 Ok(())
746 }
747
748 fn check_seq(&self, seq_id: i32) -> Result<(), Eagle3SessionError> {
749 if seq_id < 0 || seq_id.cast_unsigned() >= self.config.n_seq {
750 return Err(Eagle3SessionError::BadSeqId {
751 seq_id,
752 n_seq: self.config.n_seq,
753 });
754 }
755 Ok(())
756 }
757
758 fn sequence_index(&self, seq_id: i32) -> Result<usize, Eagle3SessionError> {
759 self.check_seq(seq_id)?;
760 usize::try_from(seq_id)
761 .map_err(|_| Eagle3SessionError::InvalidConfig("sequence id exceeds usize"))
762 }
763}
764
765fn validate_contexts(
766 target: &LlamaContext<'_>,
767 draft: &LlamaContext<'_>,
768 config: Eagle3SessionConfig,
769) -> Result<(), Eagle3SessionError> {
770 validate_contexts_common(target, draft, config)?;
771
772 // EAGLE-3 only: the draft model must name the three target layers it
773 // extracts hidden states from. Other separate-draft backends (DFlash) carry
774 // no such sites, so this check is not part of the shared validation.
775 let target_layers = target.model.n_layer();
776 let target_architecture = target
777 .model
778 .meta_val_str("general.architecture", 64)
779 .map_err(|_| {
780 Eagle3SessionError::IncompatibleContexts(
781 "target model architecture metadata is unavailable",
782 )
783 })?;
784 if !valid_target_layer_ids(
785 draft.model.target_layer_ids(),
786 target_layers,
787 target_architecture == "gpt-oss",
788 ) {
789 return Err(Eagle3SessionError::IncompatibleContexts(
790 "draft must name exactly three supported target extraction sites",
791 ));
792 }
793 Ok(())
794}
795
796/// Validation shared by every separate-draft-model speculative backend
797/// (EAGLE-3, `DFlash`): context types, sequence capacity, and batch capacity.
798fn validate_contexts_common(
799 target: &LlamaContext<'_>,
800 draft: &LlamaContext<'_>,
801 config: Eagle3SessionConfig,
802) -> Result<(), Eagle3SessionError> {
803 if target.context_type() != LlamaContextType::Default
804 || draft.context_type() != LlamaContextType::Default
805 {
806 return Err(Eagle3SessionError::IncompatibleContexts(
807 "target and draft must both be Default contexts",
808 ));
809 }
810 if target.n_seq_max() < config.n_seq || draft.n_seq_max() != config.n_seq {
811 return Err(Eagle3SessionError::IncompatibleContexts(
812 "target sequence capacity is too small or draft capacity differs from n_seq",
813 ));
814 }
815 let required_draft = u32::try_from(config.n_draft_max)
816 .map_err(|_| Eagle3SessionError::InvalidConfig("n_draft_max exceeds u32"))?;
817 validate_context_capacities(
818 SpeculativeContextCapacity {
819 batch: target.n_batch(),
820 micro_batch: target.n_ubatch(),
821 recurrent_slots: target.n_rs_seq(),
822 recurrent_or_hybrid: target.model.is_recurrent() || target.model.is_hybrid(),
823 },
824 SpeculativeContextCapacity {
825 batch: draft.n_batch(),
826 micro_batch: draft.n_ubatch(),
827 recurrent_slots: draft.n_rs_seq(),
828 recurrent_or_hybrid: draft.model.is_recurrent() || draft.model.is_hybrid(),
829 },
830 required_draft,
831 )
832 .map_err(Eagle3SessionError::IncompatibleContexts)?;
833 Ok(())
834}
835
836fn valid_target_layer_ids(
837 layer_ids: &[i32],
838 target_layers: i32,
839 terminal_nextn_site: bool,
840) -> bool {
841 target_layers > 0
842 && layer_ids.len() == 3
843 && layer_ids.iter().all(|&layer| {
844 layer >= 0 && (layer < target_layers || (layer == target_layers && terminal_nextn_site))
845 })
846}
847
848impl Drop for Eagle3Session<'_, '_, '_> {
849 fn drop(&mut self) {
850 unsafe { llama_cpp_sys_4::mtp_session_free(self.raw.as_ptr()) }
851 }
852}
853
854impl std::fmt::Debug for Eagle3Session<'_, '_, '_> {
855 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
856 f.debug_struct("Eagle3Session")
857 .field("config", &self.config)
858 .finish_non_exhaustive()
859 }
860}
861
862#[cfg(test)]
863mod tests {
864 use super::valid_target_layer_ids;
865
866 #[test]
867 fn validates_transformer_and_terminal_nextn_sites() {
868 assert!(valid_target_layer_ids(&[1, 4, 7], 8, false));
869 assert!(!valid_target_layer_ids(&[1, 4], 8, false));
870 assert!(!valid_target_layer_ids(&[1, -1, 7], 8, false));
871 assert!(!valid_target_layer_ids(&[1, 4, 8], 8, false));
872 assert!(valid_target_layer_ids(&[1, 4, 8], 8, true));
873 assert!(!valid_target_layer_ids(&[1, 4, 9], 8, true));
874 }
875
876 /// The shim dispatches on this value, so a collision with an existing spec
877 /// type would silently select the wrong speculative backend.
878 #[test]
879 fn dflash_spec_type_is_distinct() {
880 use llama_cpp_sys_4::{MTP_SPEC_TYPE_DFLASH, MTP_SPEC_TYPE_EAGLE3, MTP_SPEC_TYPE_MTP};
881 assert_ne!(MTP_SPEC_TYPE_DFLASH, MTP_SPEC_TYPE_MTP);
882 assert_ne!(MTP_SPEC_TYPE_DFLASH, MTP_SPEC_TYPE_EAGLE3);
883 // Pinned: the value is ABI, consumed by the C shim's switch.
884 assert_eq!(MTP_SPEC_TYPE_DFLASH, 2);
885 }
886
887 /// Compile-time guard that the `DFlash` surface stays reachable (the alias
888 /// and both constructors resolve).
889 #[test]
890 fn dflash_api_surface_is_exposed() {
891 // Naming each item is the guard: this stops compiling if the alias or
892 // either constructor goes missing.
893 let alias: Option<super::DFlashSession<'_, '_, '_>> = None;
894 assert!(alias.is_none());
895 let _ = super::Eagle3Session::new_dflash_with_config;
896 let _ = super::Eagle3Session::new_dflash;
897 }
898}