Skip to main content

llama_cpp_4/
llama_batch_ext.rs

1//! Safe wrapper around `llama_batch_ext`, llama.cpp's entry-wise batch.
2//!
3//! [`LlamaBatch`](crate::llama_batch::LlamaBatch) wraps the original
4//! `llama_batch`: six parallel arrays, one entry per token, and a single flat
5//! `embd` block for the whole batch. That shape cannot describe a batch where
6//! different entries carry differently-shaped inputs, which is what multimodal
7//! and speculative decoding need. `llama_batch_ext`
8//! ([llama.cpp #24669](https://github.com/ggml-org/llama.cpp/pull/24669))
9//! replaces the arrays with a list of entries that each own their token id,
10//! positions, input embedding and "state" embedding.
11//!
12//! Reach for this instead of [`LlamaBatch`](crate::llama_batch::LlamaBatch)
13//! when you need one of the things only an entry can express:
14//!
15//! - **an input embedding on some entries but not others** — a vision encoder's
16//!   output interleaved with text tokens, where `llama_batch` forces the whole
17//!   batch to be one or the other;
18//! - **M-RoPE positions**, which are `[t, y, x, z]` per embedding entry rather
19//!   than one scalar — see [`LlamaBatchExt::set_positions`];
20//! - **a state embedding** (`llama_batch_ext_set_embd_state`), the hidden state
21//!   an MTP draft context reads from the target model.
22//!
23//! For plain token decoding there is nothing to gain:
24//! [`LlamaBatch`](crate::llama_batch::LlamaBatch) is the same work with less
25//! ceremony, and `llama_decode` is where this crate's decode lifecycle hooks
26//! live — see [`LlamaContext::process`](crate::context::LlamaContext::process).
27//!
28//! # Example
29//!
30//! ```no_run
31//! use llama_cpp_4::context::LlamaContext;
32//! use llama_cpp_4::llama_batch_ext::{LlamaBatchExt, LlamaProcessType};
33//! use llama_cpp_4::token::LlamaToken;
34//!
35//! # fn f(ctx: &mut LlamaContext<'_>, tokens: &[LlamaToken])
36//! #     -> Result<(), Box<dyn std::error::Error>> {
37//! let mut batch = LlamaBatchExt::new(ctx)?;
38//!
39//! let mut pos = 0;
40//! let mut last = None;
41//! for &token in tokens {
42//!     let entry = batch.add_token(token, 0)?;
43//!     batch.set_pos(entry, pos)?;
44//!     pos += 1;
45//!     last = Some(entry);
46//! }
47//! if let Some(entry) = last {
48//!     batch.set_output(entry, true)?;
49//! }
50//!
51//! ctx.process(&mut batch, LlamaProcessType::Decode)?;
52//! # Ok(())
53//! # }
54//! ```
55
56use std::ptr::NonNull;
57
58use llama_cpp_sys_4::{llama_pos, llama_seq_id};
59
60use crate::context::LlamaContext;
61use crate::token::LlamaToken;
62
63/// Positions an embedding entry carries on an M-RoPE or interleaved-M-RoPE
64/// model: `[t, y, x, z]`, where `t` doubles as the KV-cache position.
65///
66/// Mirrors `GGML_MROPE_SECTIONS`. A model with any other `RoPE` type uses a
67/// single position per entry; [`LlamaBatchExt::n_pos_per_embd`] reports which
68/// applies.
69pub const MROPE_SECTIONS: usize = llama_cpp_sys_4::GGML_MROPE_SECTIONS as usize;
70
71/// A handle to one entry of a [`LlamaBatchExt`], returned by the `add_*`
72/// methods and accepted by the `set_*` methods.
73///
74/// This is the entry's index in the batch. It is a distinct type because every
75/// other `i32` in this module — token ids, sequence ids, positions — is also an
76/// `i32`, and mixing them up is otherwise a silent error rather than a
77/// compile error.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
79pub struct BatchEntry(i32);
80
81impl BatchEntry {
82    /// The entry's index in the batch, which is also the index to pass to
83    /// [`LlamaContext::get_logits_ith`](crate::context::LlamaContext::get_logits_ith)
84    /// after processing.
85    #[must_use]
86    pub fn index(self) -> i32 {
87        self.0
88    }
89}
90
91impl From<BatchEntry> for i32 {
92    fn from(entry: BatchEntry) -> Self {
93        entry.0
94    }
95}
96
97/// A borrowed, row-major block of embedding values, mirroring `llama_embd`.
98///
99/// llama.cpp copies the values out when the view is handed to the batch, so the
100/// borrow only has to outlive the call.
101#[derive(Debug, Clone, Copy)]
102pub struct EmbeddingRows<'a> {
103    data: &'a [f32],
104    n_rows: usize,
105    n_embd: usize,
106}
107
108impl<'a> EmbeddingRows<'a> {
109    /// A `n_rows` × `n_embd` block.
110    ///
111    /// # Errors
112    ///
113    /// Returns [`BatchExtError::EmbeddingShape`] unless
114    /// `n_rows * n_embd == data.len()`. llama.cpp takes the shape on trust and
115    /// reads `n_rows * n_embd` floats, so a wrong shape would read out of
116    /// bounds.
117    pub fn new(data: &'a [f32], n_rows: usize, n_embd: usize) -> Result<Self, BatchExtError> {
118        if n_rows.checked_mul(n_embd) != Some(data.len()) {
119            return Err(BatchExtError::EmbeddingShape {
120                n_rows,
121                n_embd,
122                len: data.len(),
123            });
124        }
125        Ok(Self {
126            data,
127            n_rows,
128            n_embd,
129        })
130    }
131
132    /// A single row of `data.len()` values — the common case.
133    #[must_use]
134    pub fn row(data: &'a [f32]) -> Self {
135        Self {
136            data,
137            n_rows: 1,
138            n_embd: data.len(),
139        }
140    }
141
142    /// Number of rows in the block.
143    #[must_use]
144    pub fn n_rows(&self) -> usize {
145        self.n_rows
146    }
147
148    /// Width of one row.
149    #[must_use]
150    pub fn n_embd(&self) -> usize {
151        self.n_embd
152    }
153
154    /// Total values in the block — the width llama.cpp fixes per batch.
155    fn total(self) -> usize {
156        self.data.len()
157    }
158
159    fn as_raw(self) -> llama_cpp_sys_4::llama_embd {
160        llama_cpp_sys_4::llama_embd {
161            data: self.data.as_ptr(),
162            n_rows: self.n_rows,
163            n_embd: self.n_embd,
164        }
165    }
166}
167
168/// Which graph to run a batch through.
169///
170/// Mirrors `llama_process_type`.
171//
172// Cast the sys constants to `u32` so the discriminants compile on both clang
173// (where bindgen emits `c_uint`, making the cast a no-op) and MSVC (where it
174// emits `c_int` and the cast is required).
175#[allow(clippy::unnecessary_cast)]
176#[repr(u32)]
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum LlamaProcessType {
179    /// Run the encoder — the equivalent of `llama_encode`.
180    Encode = llama_cpp_sys_4::LLAMA_PROCESS_TYPE_ENCODE as u32,
181    /// Run the decoder — the equivalent of `llama_decode`.
182    Decode = llama_cpp_sys_4::LLAMA_PROCESS_TYPE_DECODE as u32,
183}
184
185impl From<LlamaProcessType> for llama_cpp_sys_4::llama_process_type {
186    fn from(value: LlamaProcessType) -> Self {
187        value as u32 as Self
188    }
189}
190
191/// What a joint decision head (`clef` and friends) reads an entry as.
192///
193/// Mirrors `llama_decision_order`
194/// ([llama.cpp #29831](https://github.com/ggml-org/llama.cpp/pull/29831)). A
195/// run of entries sharing a value is one span, spans are separated by entries
196/// marked [`Self::None`], and an option belongs to the last question before it.
197/// Only decision models read this; everything else leaves it at
198/// [`Self::None`].
199#[allow(clippy::unnecessary_cast)]
200#[repr(u32)]
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
202pub enum LlamaDecisionOrder {
203    /// Not read by the decision head. The default.
204    #[default]
205    None = llama_cpp_sys_4::LLAMA_DECISION_ORDER_NONE as u32,
206    /// Text of a question.
207    QuestionNoul = llama_cpp_sys_4::LLAMA_DECISION_ORDER_QUESTION_NOUL as u32,
208    /// Question asking for a choice between the options that follow.
209    QuestionChoice = llama_cpp_sys_4::LLAMA_DECISION_ORDER_QUESTION_CHOICE as u32,
210    /// Question asking for a score.
211    QuestionScore = llama_cpp_sys_4::LLAMA_DECISION_ORDER_QUESTION_SCORE as u32,
212    /// Text of an option belonging to the preceding question.
213    Option = llama_cpp_sys_4::LLAMA_DECISION_ORDER_OPTION as u32,
214}
215
216impl From<LlamaDecisionOrder> for llama_cpp_sys_4::llama_decision_order {
217    fn from(value: LlamaDecisionOrder) -> Self {
218        value as u32 as Self
219    }
220}
221
222/// Errors from building a [`LlamaBatchExt`].
223///
224/// llama.cpp reports most of these as a bare `false` with the detail in its
225/// log, so the variants carry whatever the Rust side can establish on its own.
226#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
227pub enum BatchExtError {
228    /// `llama_batch_ext_init` returned null.
229    #[error("llama.cpp returned null from llama_batch_ext_init")]
230    NullReturn,
231    /// The batch already holds `n_batch` entries.
232    #[error("batch is full at {0} entries (the context's n_batch)")]
233    Full(u32),
234    /// The token id is negative or not below the model's vocabulary size.
235    #[error("token id {0} is out of range for this model's vocabulary")]
236    InvalidToken(i32),
237    /// The sequence id is negative or not below the context's `n_seq_max`.
238    #[error("sequence id {seq_id} is out of range (n_seq_max = {n_seq_max})")]
239    InvalidSeqId {
240        /// The rejected sequence id.
241        seq_id: llama_seq_id,
242        /// The context's `n_seq_max`.
243        n_seq_max: u32,
244    },
245    /// `n_rows * n_embd` did not match the length of the value buffer.
246    #[error("embedding shape {n_rows} x {n_embd} does not match a buffer of {len} values")]
247    EmbeddingShape {
248        /// Rows the caller declared.
249        n_rows: usize,
250        /// Row width the caller declared.
251        n_embd: usize,
252        /// Values actually supplied.
253        len: usize,
254    },
255    /// The entry handle does not belong to this batch — it came from another
256    /// batch, or from this one before [`LlamaBatchExt::clear`].
257    #[error("entry {entry} is not in this batch ({len} entries)")]
258    NoSuchEntry {
259        /// The rejected entry index.
260        entry: i32,
261        /// Entries currently in the batch.
262        len: usize,
263    },
264    /// The wrong number of positions was supplied for the entry. A token entry
265    /// takes one; an embedding entry takes
266    /// [`LlamaBatchExt::n_pos_per_embd`].
267    #[error("entry {entry} needs exactly {expected} position(s), got {got}")]
268    PositionArity {
269        /// The entry being positioned.
270        entry: i32,
271        /// Positions the entry requires.
272        expected: usize,
273        /// Positions supplied.
274        got: usize,
275    },
276    /// Every embedding in one batch must have the same total width, fixed by
277    /// the first one accepted.
278    #[error("this batch's embeddings are {expected} values wide, got {got}")]
279    EmbeddingWidthMismatch {
280        /// Width fixed by the first embedding in the batch.
281        expected: usize,
282        /// Width supplied.
283        got: usize,
284    },
285    /// The entry already carries an input embedding; llama.cpp allows one.
286    #[error("entry {0} already has an input embedding")]
287    EmbeddingAlreadySet(i32),
288    /// The entry already carries a state embedding; llama.cpp allows one.
289    #[error("entry {0} already has a state embedding")]
290    StateAlreadySet(i32),
291    /// llama.cpp rejected an embedding: the row width matches neither the
292    /// decoder nor the encoder input width, or (for a state embedding) this
293    /// context takes none — only an MTP context does. The specific reason is in
294    /// the llama.cpp log.
295    ///
296    /// When this comes from [`LlamaBatchExt::add_embedding`], llama.cpp has
297    /// already appended the entry by the time it validates the block, and
298    /// offers no way to remove one — so the batch is left holding an entry with
299    /// no input, and [`LlamaBatchExt::clear`] is the only way forward. See
300    /// [`LlamaBatchExt::has_incomplete_entry`].
301    #[error("llama.cpp rejected the embedding (see the llama.cpp log for the reason)")]
302    EmbeddingRejected,
303}
304
305/// What a batch entry carries so far.
306///
307/// This mirrors the state `llama_batch_ext` keeps per entry, because two things
308/// the C API takes on trust depend on it: the number of positions
309/// `llama_batch_ext_set_pos` reads off the caller's pointer, and whether the
310/// entry has an input at all.
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
312struct Entry {
313    /// Carries a token id, so it takes one position rather than
314    /// `n_pos_per_embd`.
315    has_token: bool,
316    /// Carries an input embedding.
317    has_embd: bool,
318    /// Carries a state embedding.
319    has_state: bool,
320}
321
322impl Entry {
323    fn token() -> Self {
324        Self {
325            has_token: true,
326            has_embd: false,
327            has_state: false,
328        }
329    }
330
331    fn embedding() -> Self {
332        Self {
333            has_token: false,
334            has_embd: true,
335            has_state: false,
336        }
337    }
338
339    /// An entry with no input at all: `add_empty` that was never filled in, or
340    /// an `add_embedding` whose block llama.cpp rejected after appending the
341    /// entry. Processing one is not meaningful.
342    fn empty() -> Self {
343        Self {
344            has_token: false,
345            has_embd: false,
346            has_state: false,
347        }
348    }
349
350    fn is_incomplete(self) -> bool {
351        !self.has_token && !self.has_embd
352    }
353
354    /// Positions `llama_batch_ext_set_pos` will read for this entry.
355    fn n_pos(self, n_pos_per_embd: usize) -> usize {
356        if self.has_token {
357            1
358        } else {
359            n_pos_per_embd
360        }
361    }
362}
363
364/// A safe wrapper around `llama_batch_ext`.
365///
366/// Build it with [`Self::new`], add entries, configure each entry through the
367/// [`BatchEntry`] handle its `add_*` returned, then hand it to
368/// [`LlamaContext::process`](crate::context::LlamaContext::process).
369///
370/// # Positions are not implied
371///
372/// Unlike [`LlamaBatch::add`](crate::llama_batch::LlamaBatch::add), adding an
373/// entry does *not* take its position — upstream leaves it undefined and
374/// expects an explicit [`Self::set_pos`]. The underlying entry starts at
375/// position `0`, so a forgotten call is not a type error. Set the position for
376/// every entry.
377///
378/// # Context binding
379///
380/// `llama_batch_ext_init` reads the context's batch size, sequence count and
381/// model geometry, and keeps a pointer to its memory module for position
382/// validation. The batch is therefore only usable with the context that built
383/// it: [`LlamaContext::process`](crate::context::LlamaContext::process) checks
384/// that and refuses anything else, so a batch outliving its context cannot be
385/// processed by a later one.
386#[derive(Debug)]
387pub struct LlamaBatchExt {
388    raw: NonNull<llama_cpp_sys_4::llama_batch_ext>,
389    /// The context that built this batch, checked by `process`. See
390    /// `LlamaContext::id`.
391    pub(crate) ctx_id: u64,
392    /// One per entry, in upstream's order. See [`Entry`].
393    entries: Vec<Entry>,
394    /// Entries with output enabled. Mirrors
395    /// [`LlamaBatch::initialized_logits`](crate::llama_batch::LlamaBatch) so
396    /// the context can validate `get_logits_ith` after processing.
397    pub(crate) initialized_logits: Vec<i32>,
398    /// Total width of this batch's embeddings, fixed by the first one accepted.
399    embd_width: Option<usize>,
400    n_pos_per_embd: usize,
401    n_batch: u32,
402    n_seq_max: u32,
403    n_vocab: i32,
404}
405
406// SAFETY: the batch is a plain owned buffer of entries. Nothing inside it is
407// shared with another thread, and every method that mutates it takes `&mut
408// self`. The context pointer it keeps internally is only dereferenced by
409// `llama_process`, which this crate reaches only through `&mut LlamaContext`.
410unsafe impl Send for LlamaBatchExt {}
411
412impl LlamaBatchExt {
413    /// Build a batch for `ctx`.
414    ///
415    /// Capacity is the context's `n_batch`; the sequence ids it will accept are
416    /// `0..n_seq_max`. Both are read from the context here, so they cannot
417    /// drift from it later.
418    ///
419    /// # Errors
420    ///
421    /// Returns [`BatchExtError::NullReturn`] if llama.cpp could not allocate.
422    pub fn new(ctx: &LlamaContext<'_>) -> Result<Self, BatchExtError> {
423        // SAFETY: `ctx.context` is a `NonNull` held by a live `&LlamaContext`.
424        // `llama_batch_ext_init` only reads the context (batch size, sequence
425        // count, model geometry) and returns an owned batch or null.
426        let raw = unsafe { llama_cpp_sys_4::llama_batch_ext_init(ctx.context.as_ptr()) };
427        let raw = NonNull::new(raw).ok_or(BatchExtError::NullReturn)?;
428
429        // Mirrors `llama_hparams::n_pos_per_embd()`, which the batch uses to
430        // decide how many positions to read for an embedding entry.
431        let rope_type = ctx.model.rope_type();
432        let n_pos_per_embd = if rope_type == llama_cpp_sys_4::LLAMA_ROPE_TYPE_MROPE
433            || rope_type == llama_cpp_sys_4::LLAMA_ROPE_TYPE_IMROPE
434        {
435            MROPE_SECTIONS
436        } else {
437            1
438        };
439
440        Ok(Self {
441            raw,
442            ctx_id: ctx.id(),
443            entries: Vec::new(),
444            initialized_logits: Vec::new(),
445            embd_width: None,
446            n_pos_per_embd,
447            n_batch: ctx.n_batch(),
448            n_seq_max: ctx.n_seq_max(),
449            n_vocab: ctx.model.n_vocab(),
450        })
451    }
452
453    /// Drop every entry, keeping the allocation for the next round.
454    ///
455    /// [`BatchEntry`] handles from before the call are no longer valid; the
456    /// `set_*` methods reject them rather than writing to a recycled entry.
457    pub fn clear(&mut self) {
458        // SAFETY: `self.raw` is live for as long as `self` — only `Drop` frees
459        // it — and `clear` just truncates the batch's own vectors.
460        unsafe { llama_cpp_sys_4::llama_batch_ext_clear(self.raw.as_ptr()) }
461        self.entries.clear();
462        self.initialized_logits.clear();
463        self.embd_width = None;
464    }
465
466    /// Number of entries in the batch.
467    #[must_use]
468    pub fn len(&self) -> usize {
469        self.entries.len()
470    }
471
472    /// Whether the batch has no entries.
473    #[must_use]
474    pub fn is_empty(&self) -> bool {
475        self.entries.is_empty()
476    }
477
478    /// Whether any entry carries no input — neither a token id nor an input
479    /// embedding — which makes the batch unprocessable.
480    ///
481    /// Two things leave one behind: [`Self::add_empty`] that was never filled
482    /// in with [`Self::set_token_embedding`], and an [`Self::add_embedding`]
483    /// whose block llama.cpp rejected *after* appending the entry. There is no
484    /// upstream call to remove an entry, so the way out is [`Self::clear`].
485    /// [`LlamaContext::process`](crate::context::LlamaContext::process) checks
486    /// this rather than submitting a batch with a hole in it.
487    #[must_use]
488    pub fn has_incomplete_entry(&self) -> bool {
489        self.entries.iter().any(|entry| entry.is_incomplete())
490    }
491
492    /// Index of the first entry carrying no input, if any.
493    pub(crate) fn first_incomplete_entry(&self) -> Option<i32> {
494        let idx = self
495            .entries
496            .iter()
497            .position(|entry| entry.is_incomplete())?;
498        i32::try_from(idx).ok()
499    }
500
501    /// Maximum entries this batch can hold — the context's `n_batch`.
502    #[must_use]
503    pub fn capacity(&self) -> u32 {
504        self.n_batch
505    }
506
507    /// Positions an embedding entry in this batch requires:
508    /// [`MROPE_SECTIONS`] on an M-RoPE model, `1` otherwise. A token entry
509    /// always takes one.
510    #[must_use]
511    pub fn n_pos_per_embd(&self) -> usize {
512        self.n_pos_per_embd
513    }
514
515    /// Entries with output enabled, in the order they were enabled.
516    #[must_use]
517    pub fn outputs(&self) -> &[i32] {
518        &self.initialized_logits
519    }
520
521    /// Add a token entry for sequence `seq_id`.
522    ///
523    /// Set its position with [`Self::set_pos`]; upstream leaves it undefined.
524    ///
525    /// # Errors
526    ///
527    /// [`BatchExtError::Full`] when the batch is at capacity,
528    /// [`BatchExtError::InvalidSeqId`] for an out-of-range sequence, or
529    /// [`BatchExtError::InvalidToken`] for a token id outside the vocabulary.
530    pub fn add_token(
531        &mut self,
532        LlamaToken(id): LlamaToken,
533        seq_id: llama_seq_id,
534    ) -> Result<BatchEntry, BatchExtError> {
535        // Checked here rather than left to upstream: `llama_batch_ext_add_token`
536        // appends the entry and *then* validates the id, so a bad id would
537        // leave an entry with no input behind.
538        if id < 0 || id >= self.n_vocab {
539            return Err(BatchExtError::InvalidToken(id));
540        }
541        let idx =
542            // SAFETY: `self.raw` is live, and `id` was range-checked against
543            // `n_vocab` just above so the call cannot take the post-append
544            // rejection path. `seq_id` is validated by upstream, which returns
545            // `-3` rather than misbehaving.
546            unsafe { llama_cpp_sys_4::llama_batch_ext_add_token(self.raw.as_ptr(), seq_id, id) };
547        self.finish_add(idx, seq_id, Entry::token(), BatchExtError::InvalidToken(id))
548    }
549
550    /// Add an embedding-only entry for sequence `seq_id`.
551    ///
552    /// The row width must match the model's decoder input width
553    /// (`n_embd_inp`) or its encoder input width, and every embedding in one
554    /// batch must have the same width.
555    ///
556    /// Positions come from [`Self::set_positions`]: an embedding entry takes
557    /// [`Self::n_pos_per_embd`] of them, not one.
558    ///
559    /// # Errors
560    ///
561    /// [`BatchExtError::Full`], [`BatchExtError::InvalidSeqId`],
562    /// [`BatchExtError::EmbeddingWidthMismatch`] if it disagrees with the
563    /// batch's established width, or [`BatchExtError::EmbeddingRejected`] if
564    /// llama.cpp would not take the block.
565    ///
566    /// `EmbeddingRejected` here leaves an entry with no input in the batch —
567    /// upstream appends before it validates, and offers no way to remove one.
568    /// See [`Self::has_incomplete_entry`].
569    pub fn add_embedding(
570        &mut self,
571        embd: EmbeddingRows<'_>,
572        seq_id: llama_seq_id,
573    ) -> Result<BatchEntry, BatchExtError> {
574        self.check_embd_width(embd)?;
575        // SAFETY: `self.raw` is live. `embd.as_raw()` describes a block whose
576        // `n_rows * n_embd` equals the length of the borrowed slice —
577        // `EmbeddingRows::new` enforces that — so llama.cpp reads exactly the
578        // values it is given, and it copies them before returning.
579        let idx = unsafe {
580            llama_cpp_sys_4::llama_batch_ext_add_embd(self.raw.as_ptr(), seq_id, embd.as_raw())
581        };
582        let entry = self.finish_add(
583            idx,
584            seq_id,
585            Entry::embedding(),
586            BatchExtError::EmbeddingRejected,
587        );
588        if entry.is_ok() {
589            self.embd_width = Some(embd.total());
590        }
591        entry
592    }
593
594    /// Add an entry with neither a token id nor an embedding, to be filled in
595    /// with [`Self::set_token_embedding`].
596    ///
597    /// Treated as an embedding entry for positioning: it takes
598    /// [`Self::n_pos_per_embd`] positions.
599    ///
600    /// Until it is filled in, the entry counts as incomplete and
601    /// [`LlamaContext::process`](crate::context::LlamaContext::process) will
602    /// refuse the batch — see [`Self::has_incomplete_entry`].
603    ///
604    /// # Errors
605    ///
606    /// [`BatchExtError::Full`] or [`BatchExtError::InvalidSeqId`].
607    pub fn add_empty(&mut self, seq_id: llama_seq_id) -> Result<BatchEntry, BatchExtError> {
608        // SAFETY: `self.raw` is live; the call takes only scalars besides it.
609        let idx = unsafe { llama_cpp_sys_4::llama_batch_ext_add(self.raw.as_ptr(), seq_id) };
610        // `llama_batch_ext_add` has no -2 case; any negative is -1 or -3.
611        self.finish_add(
612            idx,
613            seq_id,
614            Entry::empty(),
615            BatchExtError::Full(self.n_batch),
616        )
617    }
618
619    /// Map the `i32` an `add_*` returned onto a [`BatchEntry`] or an error, and
620    /// record the entry's kind.
621    ///
622    /// Upstream's codes are `-1` full, `-2` the input was rejected (what that
623    /// means depends on which `add_*` was called, hence `rejected`), `-3`
624    /// invalid sequence id.
625    fn finish_add(
626        &mut self,
627        idx: i32,
628        seq_id: llama_seq_id,
629        entry: Entry,
630        rejected: BatchExtError,
631    ) -> Result<BatchEntry, BatchExtError> {
632        match idx {
633            // `-1` (full) and `-3` (bad sequence id) are raised before the
634            // entry is appended, so there is nothing to mirror.
635            -1 => Err(BatchExtError::Full(self.n_batch)),
636            -3 => Err(BatchExtError::InvalidSeqId {
637                seq_id,
638                n_seq_max: self.n_seq_max,
639            }),
640            // `-2` is raised *after* it was appended — the input was rejected,
641            // not the entry. Mirror it anyway, or every later index would be
642            // off by one against upstream's.
643            -2 => {
644                self.entries.push(Entry::empty());
645                Err(rejected)
646            }
647            idx if idx < 0 => Err(rejected),
648            idx => {
649                // `add_*` appends, so the new entry is always at `len()`. Keep
650                // the mirror in step even if upstream ever renumbers.
651                debug_assert_eq!(usize::try_from(idx).ok(), Some(self.entries.len()));
652                self.entries.push(entry);
653                Ok(BatchEntry(idx))
654            }
655        }
656    }
657
658    /// Also feed this entry to `seq_id`. The entry keeps the sequence it was
659    /// added with.
660    ///
661    /// # Errors
662    ///
663    /// [`BatchExtError::NoSuchEntry`] for a stale handle, or
664    /// [`BatchExtError::InvalidSeqId`] for an out-of-range sequence.
665    pub fn add_seq(
666        &mut self,
667        entry: BatchEntry,
668        seq_id: llama_seq_id,
669    ) -> Result<(), BatchExtError> {
670        self.check_entry(entry)?;
671        let ok =
672            // SAFETY: `self.raw` is live and `check_entry` above proved
673            // `entry.0` indexes an entry that exists in this batch.
674            unsafe { llama_cpp_sys_4::llama_batch_ext_add_seq(self.raw.as_ptr(), entry.0, seq_id) };
675        if ok {
676            Ok(())
677        } else {
678            Err(BatchExtError::InvalidSeqId {
679                seq_id,
680                n_seq_max: self.n_seq_max,
681            })
682        }
683    }
684
685    /// Set the position of a token entry.
686    ///
687    /// # Errors
688    ///
689    /// [`BatchExtError::NoSuchEntry`] for a stale handle, or
690    /// [`BatchExtError::PositionArity`] if the entry is an embedding entry on
691    /// an M-RoPE model and needs [`Self::n_pos_per_embd`] positions — use
692    /// [`Self::set_positions`] for those.
693    pub fn set_pos(&mut self, entry: BatchEntry, pos: llama_pos) -> Result<(), BatchExtError> {
694        self.set_positions(entry, &[pos])
695    }
696
697    /// Set all positions of an entry.
698    ///
699    /// `pos` must hold exactly the number of positions the entry takes: one for
700    /// a token entry, [`Self::n_pos_per_embd`] for an embedding entry. On an
701    /// M-RoPE model that is `[t, y, x, z]`, where `t` is also the KV-cache
702    /// position.
703    ///
704    /// # Errors
705    ///
706    /// [`BatchExtError::NoSuchEntry`] for a stale handle, or
707    /// [`BatchExtError::PositionArity`] for the wrong number of positions —
708    /// llama.cpp reads the arity it expects straight off the pointer, so a
709    /// short slice would read out of bounds.
710    pub fn set_positions(
711        &mut self,
712        entry: BatchEntry,
713        pos: &[llama_pos],
714    ) -> Result<(), BatchExtError> {
715        let expected = self.check_entry(entry)?.n_pos(self.n_pos_per_embd);
716        if pos.len() != expected {
717            return Err(BatchExtError::PositionArity {
718                entry: entry.0,
719                expected,
720                got: pos.len(),
721            });
722        }
723        // SAFETY: `self.raw` is live and `check_entry` proved `entry.0` exists.
724        // The critical part is the arity: upstream reads one `llama_pos` for a
725        // token entry and `n_pos_per_embd` for an embedding one, straight off
726        // this pointer, and the length check above proved `pos` holds exactly
727        // that many.
728        let ok = unsafe {
729            llama_cpp_sys_4::llama_batch_ext_set_pos(self.raw.as_ptr(), entry.0, pos.as_ptr())
730        };
731        debug_assert!(ok, "set_pos rejected an entry that check_entry accepted");
732        Ok(())
733    }
734
735    /// Attach an input embedding to an entry that already has a token id, so
736    /// the entry carries both.
737    ///
738    /// # Errors
739    ///
740    /// [`BatchExtError::NoSuchEntry`] for a stale handle,
741    /// [`BatchExtError::EmbeddingAlreadySet`] if the entry has one,
742    /// [`BatchExtError::EmbeddingWidthMismatch`] if it disagrees with the
743    /// batch's established width, or [`BatchExtError::EmbeddingRejected`] if
744    /// llama.cpp would not take the block.
745    pub fn set_token_embedding(
746        &mut self,
747        entry: BatchEntry,
748        embd: EmbeddingRows<'_>,
749    ) -> Result<(), BatchExtError> {
750        if self.check_entry(entry)?.has_embd {
751            return Err(BatchExtError::EmbeddingAlreadySet(entry.0));
752        }
753        self.check_embd_width(embd)?;
754        // SAFETY: `self.raw` is live, `check_entry` proved `entry.0` exists,
755        // and `EmbeddingRows` guarantees the block's declared shape matches the
756        // slice behind it. llama.cpp copies the values out before returning.
757        let ok = unsafe {
758            llama_cpp_sys_4::llama_batch_ext_set_embd_token(
759                self.raw.as_ptr(),
760                entry.0,
761                embd.as_raw(),
762            )
763        };
764        if !ok {
765            return Err(BatchExtError::EmbeddingRejected);
766        }
767        self.embd_width = Some(embd.total());
768        self.mark(entry, |slot| slot.has_embd = true);
769        Ok(())
770    }
771
772    /// Attach a *state* embedding to an entry — hidden state carried over from
773    /// an earlier stage, such as the target model's layers for MTP or a vision
774    /// encoder's deepstack output.
775    ///
776    /// # Errors
777    ///
778    /// [`BatchExtError::NoSuchEntry`] for a stale handle,
779    /// [`BatchExtError::StateAlreadySet`] if the entry has one, or
780    /// [`BatchExtError::EmbeddingRejected`] when the width is wrong or this
781    /// context takes no state embedding at all — only an MTP context does, so
782    /// check
783    /// [`LlamaContext::context_type`](crate::context::LlamaContext::context_type).
784    pub fn set_state_embedding(
785        &mut self,
786        entry: BatchEntry,
787        state: EmbeddingRows<'_>,
788    ) -> Result<(), BatchExtError> {
789        if self.check_entry(entry)?.has_state {
790            return Err(BatchExtError::StateAlreadySet(entry.0));
791        }
792        // SAFETY: as for `set_token_embedding`. A context that takes no state
793        // embedding is reported by upstream as `false`, not misread.
794        let ok = unsafe {
795            llama_cpp_sys_4::llama_batch_ext_set_embd_state(
796                self.raw.as_ptr(),
797                entry.0,
798                state.as_raw(),
799            )
800        };
801        if !ok {
802            return Err(BatchExtError::EmbeddingRejected);
803        }
804        self.mark(entry, |slot| slot.has_state = true);
805        Ok(())
806    }
807
808    /// Enable or disable output for an entry, making its logits and embeddings
809    /// readable after processing.
810    ///
811    /// Upstream exposes this twice — `llama_batch_ext_set_output_logits` and
812    /// `llama_batch_ext_set_output_embd` — and the two call the same setter, so
813    /// there is one method here. Should they ever diverge, this one keeps the
814    /// logits meaning.
815    ///
816    /// # Errors
817    ///
818    /// [`BatchExtError::NoSuchEntry`] for a stale handle.
819    pub fn set_output(&mut self, entry: BatchEntry, value: bool) -> Result<(), BatchExtError> {
820        self.check_entry(entry)?;
821        // SAFETY: `self.raw` is live and `check_entry` proved `entry.0` exists;
822        // the rest of the call is a `bool`.
823        let ok = unsafe {
824            llama_cpp_sys_4::llama_batch_ext_set_output_logits(self.raw.as_ptr(), entry.0, value)
825        };
826        debug_assert!(ok, "set_output rejected an entry that check_entry accepted");
827
828        if value {
829            if !self.initialized_logits.contains(&entry.0) {
830                self.initialized_logits.push(entry.0);
831            }
832        } else {
833            self.initialized_logits.retain(|i| *i != entry.0);
834        }
835        Ok(())
836    }
837
838    /// Mark what a joint decision head should read this entry as.
839    ///
840    /// Only decision models (`clef` and friends) look at this; see
841    /// [`LlamaDecisionOrder`].
842    ///
843    /// # Errors
844    ///
845    /// [`BatchExtError::NoSuchEntry`] for a stale handle.
846    pub fn set_decision_order(
847        &mut self,
848        entry: BatchEntry,
849        order: LlamaDecisionOrder,
850    ) -> Result<(), BatchExtError> {
851        self.check_entry(entry)?;
852        // SAFETY: `self.raw` is live, `check_entry` proved `entry.0` exists,
853        // and `order.into()` can only produce one of the `llama_decision_order`
854        // discriminants.
855        let ok = unsafe {
856            llama_cpp_sys_4::llama_batch_ext_set_decision_order(
857                self.raw.as_ptr(),
858                entry.0,
859                order.into(),
860            )
861        };
862        debug_assert!(
863            ok,
864            "set_decision_order rejected an entry that check_entry accepted"
865        );
866        Ok(())
867    }
868
869    /// Validate an entry handle against this batch, returning its state.
870    fn check_entry(&self, entry: BatchEntry) -> Result<Entry, BatchExtError> {
871        usize::try_from(entry.0)
872            .ok()
873            .and_then(|idx| self.entries.get(idx))
874            .copied()
875            .ok_or(BatchExtError::NoSuchEntry {
876                entry: entry.0,
877                len: self.entries.len(),
878            })
879    }
880
881    /// Reject an embedding that disagrees with the width the batch settled on,
882    /// which upstream would otherwise reject with the detail only in its log.
883    fn check_embd_width(&self, embd: EmbeddingRows<'_>) -> Result<(), BatchExtError> {
884        match self.embd_width {
885            Some(expected) if expected != embd.total() => {
886                Err(BatchExtError::EmbeddingWidthMismatch {
887                    expected,
888                    got: embd.total(),
889                })
890            }
891            _ => Ok(()),
892        }
893    }
894
895    /// Record that `entry` gained an input or state embedding.
896    fn mark(&mut self, entry: BatchEntry, update: impl FnOnce(&mut Entry)) {
897        if let Ok(idx) = usize::try_from(entry.0) {
898            if let Some(slot) = self.entries.get_mut(idx) {
899                update(slot);
900            }
901        }
902    }
903
904    pub(crate) fn as_ptr(&mut self) -> *mut llama_cpp_sys_4::llama_batch_ext {
905        self.raw.as_ptr()
906    }
907}
908
909impl Drop for LlamaBatchExt {
910    fn drop(&mut self) {
911        // `llama_batch_ext` has no destructor beyond freeing its own vectors —
912        // it never dereferences the context pointer it holds — so this is safe
913        // even once the originating context is gone.
914        // SAFETY: `self.raw` came from `llama_batch_ext_init` in `new` and is
915        // freed exactly once, here; `LlamaBatchExt` is neither `Copy` nor
916        // `Clone`. As the comment above notes, the destructor never touches
917        // the context pointer the batch holds, so this is sound even once that
918        // context is gone.
919        unsafe { llama_cpp_sys_4::llama_batch_ext_free(self.raw.as_ptr()) }
920    }
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926
927    #[test]
928    fn embedding_rows_rejects_a_shape_that_does_not_match_the_buffer() {
929        let data = [0.0_f32; 6];
930        assert_eq!(
931            EmbeddingRows::new(&data, 2, 4).unwrap_err(),
932            BatchExtError::EmbeddingShape {
933                n_rows: 2,
934                n_embd: 4,
935                len: 6
936            }
937        );
938        let rows = EmbeddingRows::new(&data, 2, 3).unwrap();
939        assert_eq!((rows.n_rows(), rows.n_embd()), (2, 3));
940    }
941
942    #[test]
943    fn embedding_rows_row_is_one_row_of_everything() {
944        let data = [1.0_f32, 2.0, 3.0];
945        let rows = EmbeddingRows::row(&data);
946        assert_eq!((rows.n_rows(), rows.n_embd()), (1, 3));
947    }
948
949    /// `n_rows * n_embd` must not be allowed to wrap into a length that happens
950    /// to match — that would hand llama.cpp a pointer with a bogus extent.
951    #[test]
952    fn embedding_rows_shape_does_not_wrap() {
953        let data = [0.0_f32; 1];
954        assert!(EmbeddingRows::new(&data, usize::MAX, 2).is_err());
955    }
956
957    #[test]
958    fn mrope_sections_matches_upstream() {
959        assert_eq!(MROPE_SECTIONS, 4);
960    }
961
962    #[test]
963    fn process_type_maps_onto_the_sys_constants() {
964        assert_eq!(
965            llama_cpp_sys_4::llama_process_type::from(LlamaProcessType::Encode),
966            llama_cpp_sys_4::LLAMA_PROCESS_TYPE_ENCODE
967        );
968        assert_eq!(
969            llama_cpp_sys_4::llama_process_type::from(LlamaProcessType::Decode),
970            llama_cpp_sys_4::LLAMA_PROCESS_TYPE_DECODE
971        );
972    }
973
974    #[test]
975    fn decision_order_maps_onto_the_sys_constants() {
976        assert_eq!(
977            llama_cpp_sys_4::llama_decision_order::from(LlamaDecisionOrder::None),
978            llama_cpp_sys_4::LLAMA_DECISION_ORDER_NONE
979        );
980        assert_eq!(
981            llama_cpp_sys_4::llama_decision_order::from(LlamaDecisionOrder::Option),
982            llama_cpp_sys_4::LLAMA_DECISION_ORDER_OPTION
983        );
984    }
985}