Skip to main content

llama_cpp_4/
mtp.rs

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