llama_cpp_4/common_sampler.rs
1//! llama.cpp's assembled sampler chain, and the reasoning-budget sampler.
2//!
3//! [`LlamaSampler`](crate::sampling::LlamaSampler) wraps the individual
4//! `llama_sampler_*` primitives, leaving the caller to build a chain. This
5//! wraps `common_sampler` — the chain upstream assembles for its own tools —
6//! which is a different trade: less control, but it gets right several things
7//! that are easy to miss when hand-rolling.
8//!
9//! - **Ordering.** Penalties and DRY run before truncation samplers, which run
10//! before temperature. A chain in the wrong order silently samples from the
11//! wrong distribution.
12//! - **Grammar prefill.** Output-format and tool-call grammars are advanced
13//! past the generation prompt; user grammars are not. See
14//! [`ChatParams::grammar_sampler`](crate::chat::ChatParams::grammar_sampler)
15//! for what goes wrong otherwise.
16//! - **Model-declared suppress tokens** are merged into the logit bias, so
17//! `tokenizer.ggml.suppress_tokens` is honoured without the caller knowing it
18//! exists.
19//! - **Reasoning budget**, created whenever a lazy grammar is active so a
20//! thinking block can be force-closed.
21//! - **Speculative acceptance** via [`CommonSampler::sample_and_accept_n`].
22
23use std::ffi::{c_char, CString};
24use std::ptr::NonNull;
25
26use llama_cpp_sys_4 as sys;
27
28use crate::context::LlamaContext;
29use crate::model::LlamaModel;
30use crate::token::LlamaToken;
31
32/// Errors from the common-sampler layer.
33///
34/// An alias for [`ShimError`](crate::shim::ShimError) — every shim-backed
35/// module shares one error type, since they share one status enum and one error
36/// buffer.
37pub type CommonSamplerError = crate::shim::ShimError;
38
39use crate::shim::{check_status, last_error, read_i32s, read_string, read_tokens, Result};
40
41/// Which of llama.cpp's samplers to run, and in what order.
42///
43/// Values match `common_sampler_type`; the default chain is penalties → DRY →
44/// top-n-sigma → top-k → typical-p → top-p → min-p → XTC → temperature.
45// Discriminants are an ABI contract with `common_sampler_type`, and they are
46// not contiguous: 5 is a retired TFS-Z slot upstream left as a comment. Taken
47// verbatim from `common/common.h`; the round-trip test pins them.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49#[repr(i32)]
50pub enum CommonSamplerType {
51 /// DRY repetition penalty.
52 Dry = 1,
53 /// Top-k truncation.
54 TopK = 2,
55 /// Top-p (nucleus) truncation.
56 TopP = 3,
57 /// Min-p truncation.
58 MinP = 4,
59 /// Typical-p truncation.
60 TypicalP = 6,
61 /// Temperature scaling.
62 Temperature = 7,
63 /// Exclude Top Choices.
64 Xtc = 8,
65 /// Infill sampler, for fill-in-the-middle.
66 Infill = 9,
67 /// Repetition / frequency / presence penalties.
68 Penalties = 10,
69 /// Top-n-sigma truncation.
70 TopNSigma = 11,
71 /// Adaptive-p, which targets a probability rather than a cutoff.
72 AdaptiveP = 12,
73}
74
75impl CommonSamplerType {
76 /// llama.cpp's own name for this sampler, e.g. `"top_k"`.
77 ///
78 /// # Errors
79 ///
80 /// Returns [`CommonSamplerError::Failed`] if llama.cpp cannot name it.
81 pub fn name(self) -> Result<String> {
82 // SAFETY: the closure hands the shim a buffer pointer together with its capacity,
83 // and the shim reports how much it needed; nothing is read back unless it
84 // reported success.
85 read_string(|buf, len, expected| unsafe {
86 sys::common_shim_sampler_type_to_str(self as i32, buf, len, expected)
87 })
88 }
89
90 /// Parse llama.cpp's sampler names into an ordering.
91 ///
92 /// Unrecognised names are dropped by llama.cpp rather than rejected, so a
93 /// shorter result than `names` means something was not understood.
94 ///
95 /// # Errors
96 ///
97 /// Returns [`CommonSamplerError::Nul`] if a name contains an interior NUL.
98 pub fn from_names(names: &[&str]) -> Result<Vec<i32>> {
99 let c_names: Vec<CString> = names
100 .iter()
101 .map(|n| CString::new(*n))
102 .collect::<std::result::Result<_, _>>()?;
103 let ptrs: Vec<*const c_char> = c_names.iter().map(|c| c.as_ptr()).collect();
104
105 // SAFETY: the closure hands the shim a buffer pointer together with its capacity,
106 // and the shim reports how much it needed; nothing is read back unless it
107 // reported success.
108 read_i32s(|out, cap, len| unsafe {
109 sys::common_shim_sampler_types_from_names(ptrs.as_ptr(), ptrs.len(), out, cap, len)
110 })
111 }
112}
113
114/// How a grammar was obtained, which decides whether the generation prompt is
115/// prefilled into it.
116///
117/// Getting this wrong is silent: prefilling a user grammar consumes tokens it
118/// never expected, and *not* prefilling a tool-call grammar forces the model to
119/// re-emit the generation prompt.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
121pub enum GrammarSource {
122 /// No grammar.
123 #[default]
124 None,
125 /// Supplied verbatim by the caller. Never prefilled.
126 User,
127 /// Generated from a JSON schema. Prefilled.
128 OutputFormat,
129 /// Generated by a chat template for tool calling. Prefilled.
130 ToolCalls,
131}
132
133impl GrammarSource {
134 // The C enum is unsigned; the field it feeds is `int32_t`.
135 #[allow(clippy::cast_possible_wrap)]
136 fn as_raw(self) -> i32 {
137 let raw = match self {
138 Self::None => sys::COMMON_SHIM_GRAMMAR_NONE,
139 Self::User => sys::COMMON_SHIM_GRAMMAR_USER,
140 Self::OutputFormat => sys::COMMON_SHIM_GRAMMAR_OUTPUT_FORMAT,
141 Self::ToolCalls => sys::COMMON_SHIM_GRAMMAR_TOOL_CALLS,
142 };
143 raw as i32
144 }
145}
146
147/// Parameters for [`CommonSampler::new`], seeded from llama.cpp's defaults.
148///
149/// The scalar knobs are a plain public struct ([`CommonSamplerScalars`]);
150/// everything backed by a container is set through a method, which keeps this
151/// wrapper stable when upstream adds fields.
152pub struct CommonSamplerParams {
153 raw: NonNull<sys::common_shim_sampler_params>,
154}
155
156impl std::fmt::Debug for CommonSamplerParams {
157 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 f.debug_struct("CommonSamplerParams")
159 .field("scalars", &self.scalars())
160 .finish_non_exhaustive()
161 }
162}
163
164// SAFETY: the handle owns a `common_params_sampling` with no shared state; the
165// shim's only global is a thread-local error buffer.
166unsafe impl Send for CommonSamplerParams {}
167
168impl Drop for CommonSamplerParams {
169 fn drop(&mut self) {
170 // SAFETY: this runs once from `Drop` on a non-null handle, and nothing reads it
171 // afterwards.
172 unsafe { sys::common_shim_sampler_params_free(self.raw.as_ptr()) }
173 }
174}
175
176/// The scalar half of llama.cpp's sampling parameters.
177pub type CommonSamplerScalars = sys::common_shim_sampler_scalars;
178
179impl Default for CommonSamplerParams {
180 fn default() -> Self {
181 Self::new()
182 }
183}
184
185impl CommonSamplerParams {
186 /// Allocate with llama.cpp's defaults (temp 0.8, top-k 40, top-p 0.95, …).
187 ///
188 /// # Panics
189 ///
190 /// Panics if the allocation fails.
191 #[must_use]
192 pub fn new() -> Self {
193 // SAFETY: no borrowed pointer is involved beyond arguments alive for the call;
194 // the returned handle is checked for null before being wrapped.
195 let raw = unsafe { sys::common_shim_sampler_params_init() };
196 Self {
197 raw: NonNull::new(raw).expect("common_shim_sampler_params_init returned null"),
198 }
199 }
200
201 /// Read the scalar knobs.
202 #[must_use]
203 pub fn scalars(&self) -> CommonSamplerScalars {
204 // SAFETY: the struct is a plain-old-data `repr(C)` type with no pointer or
205 // reference fields, so an all-zero value is a valid instance; the shim
206 // fills it in immediately below.
207 let mut out: CommonSamplerScalars = unsafe { std::mem::zeroed() };
208 // SAFETY: the `CString` arguments are alive for the duration of the call, and any
209 // out-param is a stack local the shim writes through.
210 unsafe { sys::common_shim_sampler_params_get_scalars(self.raw.as_ptr(), &raw mut out) };
211 out
212 }
213
214 /// Replace the scalar knobs. Read [`Self::scalars`] first and modify it, so
215 /// fields you do not care about keep llama.cpp's defaults.
216 pub fn set_scalars(&mut self, scalars: &CommonSamplerScalars) {
217 // SAFETY: the handle is non-null and kept alive by this borrow, and the remaining
218 // arguments are scalars the library validates itself.
219 unsafe { sys::common_shim_sampler_params_set_scalars(self.raw.as_ptr(), scalars) }
220 }
221
222 /// Constrain sampling with a GBNF grammar.
223 ///
224 /// `source` decides whether the generation prompt is prefilled — see
225 /// [`GrammarSource`].
226 ///
227 /// # Errors
228 ///
229 /// Returns [`CommonSamplerError::Nul`] for an interior NUL in `grammar`.
230 pub fn set_grammar(&mut self, grammar: &str, source: GrammarSource, lazy: bool) -> Result<()> {
231 let c_grammar = CString::new(grammar)?;
232 // SAFETY: the handle is non-null and kept alive by this borrow, and the remaining
233 // arguments are scalars the library validates itself.
234 let status = unsafe {
235 sys::common_shim_sampler_params_set_grammar(
236 self.raw.as_ptr(),
237 c_grammar.as_ptr(),
238 source.as_raw(),
239 lazy,
240 )
241 };
242 check_status(status)
243 }
244
245 /// Add a trigger that activates a lazy grammar.
246 ///
247 /// `kind` matches [`GrammarTrigger::kind`](crate::chat::GrammarTrigger):
248 /// `"token"`, `"word"`, `"pattern"` or `"pattern_full"`.
249 ///
250 /// # Errors
251 ///
252 /// Returns [`CommonSamplerError::InvalidArg`] for an unknown `kind`, or
253 /// [`CommonSamplerError::Nul`] for an interior NUL.
254 pub fn add_grammar_trigger(
255 &mut self,
256 kind: &str,
257 value: &str,
258 token: LlamaToken,
259 ) -> Result<()> {
260 let raw_kind = match kind {
261 "token" => 0,
262 "word" => 1,
263 "pattern" => 2,
264 "pattern_full" => 3,
265 _ => return Err(CommonSamplerError::InvalidArg),
266 };
267 let c_value = CString::new(value)?;
268 // SAFETY: the `CString` arguments are alive for the duration of the call, and any
269 // out-param is a stack local the shim writes through.
270 let status = unsafe {
271 sys::common_shim_sampler_params_add_grammar_trigger(
272 self.raw.as_ptr(),
273 raw_kind,
274 c_value.as_ptr(),
275 token.0,
276 )
277 };
278 check_status(status)
279 }
280
281 /// Set the generation prompt the grammar should be advanced past.
282 ///
283 /// # Errors
284 ///
285 /// Returns [`CommonSamplerError::Nul`] for an interior NUL.
286 pub fn set_generation_prompt(&mut self, prompt: &str) -> Result<()> {
287 let c_prompt = CString::new(prompt)?;
288 // SAFETY: the handle is non-null and kept alive by this borrow, and the remaining
289 // arguments are scalars the library validates itself.
290 let status = unsafe {
291 sys::common_shim_sampler_params_set_generation_prompt(
292 self.raw.as_ptr(),
293 c_prompt.as_ptr(),
294 )
295 };
296 check_status(status)
297 }
298
299 /// Bias a token's logit. Use `f32::NEG_INFINITY` to ban it outright.
300 ///
301 /// # Errors
302 ///
303 /// Returns [`CommonSamplerError::InvalidArg`] if the params handle is bad.
304 pub fn add_logit_bias(&mut self, token: LlamaToken, bias: f32) -> Result<()> {
305 // SAFETY: the handle is non-null and kept alive by this borrow, and the remaining
306 // arguments are scalars the library validates itself.
307 let status = unsafe {
308 sys::common_shim_sampler_params_add_logit_bias(self.raw.as_ptr(), token.0, bias)
309 };
310 check_status(status)
311 }
312
313 /// Replace the sampler ordering.
314 ///
315 /// # Errors
316 ///
317 /// Returns [`CommonSamplerError::InvalidArg`] if the params handle is bad.
318 pub fn set_samplers(&mut self, samplers: &[CommonSamplerType]) -> Result<()> {
319 let raw: Vec<i32> = samplers.iter().map(|s| *s as i32).collect();
320 // SAFETY: the handle is non-null and kept alive by this borrow, and the remaining
321 // arguments are scalars the library validates itself.
322 let status = unsafe {
323 sys::common_shim_sampler_params_set_samplers(self.raw.as_ptr(), raw.as_ptr(), raw.len())
324 };
325 check_status(status)
326 }
327
328 /// Replace the DRY sequence breakers (default: newline, `:`, `"`, `*`).
329 ///
330 /// # Errors
331 ///
332 /// Returns [`CommonSamplerError::Nul`] for an interior NUL.
333 pub fn set_dry_breakers(&mut self, breakers: &[&str]) -> Result<()> {
334 let c_breakers: Vec<CString> = breakers
335 .iter()
336 .map(|b| CString::new(*b))
337 .collect::<std::result::Result<_, _>>()?;
338 let ptrs: Vec<*const c_char> = c_breakers.iter().map(|c| c.as_ptr()).collect();
339 // SAFETY: the handle is non-null and kept alive by this borrow, and the remaining
340 // arguments are scalars the library validates itself.
341 let status = unsafe {
342 sys::common_shim_sampler_params_set_dry_breakers(
343 self.raw.as_ptr(),
344 ptrs.as_ptr(),
345 ptrs.len(),
346 )
347 };
348 check_status(status)
349 }
350
351 /// Cap how many tokens the model may spend inside a reasoning block.
352 ///
353 /// `start` is the tokenized opening tag (`<think>`), `ends` the closing
354 /// tags — the first doubles as the forcing sequence. `forced` is emitted
355 /// when the budget runs out, typically a truncation message followed by the
356 /// closing tag. Set the budget itself through
357 /// [`CommonSamplerScalars::reasoning_budget_tokens`].
358 ///
359 /// # Errors
360 ///
361 /// Returns [`CommonSamplerError::Nul`] for an interior NUL in `message`.
362 pub fn set_reasoning_budget(
363 &mut self,
364 start: &[LlamaToken],
365 ends: &[Vec<LlamaToken>],
366 forced: &[LlamaToken],
367 message: &str,
368 ) -> Result<()> {
369 let start_raw: Vec<i32> = start.iter().map(|t| t.0).collect();
370 let (ends_flat, end_lens) = flatten(ends);
371 let forced_raw: Vec<i32> = forced.iter().map(|t| t.0).collect();
372 let c_message = CString::new(message)?;
373
374 // SAFETY: the handle is non-null and kept alive by this borrow, and the remaining
375 // arguments are scalars the library validates itself.
376 let status = unsafe {
377 sys::common_shim_sampler_params_set_reasoning_budget(
378 self.raw.as_ptr(),
379 start_raw.as_ptr(),
380 start_raw.len(),
381 ends_flat.as_ptr(),
382 end_lens.as_ptr(),
383 end_lens.len(),
384 forced_raw.as_ptr(),
385 forced_raw.len(),
386 c_message.as_ptr(),
387 )
388 };
389 check_status(status)
390 }
391}
392
393/// Flatten a slice of token sequences into `(data, lengths)`, which is how the
394/// shim takes ragged arrays without arrays-of-pointers.
395fn flatten(seqs: &[Vec<LlamaToken>]) -> (Vec<i32>, Vec<usize>) {
396 let mut data = Vec::new();
397 let mut lens = Vec::with_capacity(seqs.len());
398 for seq in seqs {
399 lens.push(seq.len());
400 data.extend(seq.iter().map(|t| t.0));
401 }
402 (data, lens)
403}
404
405/// llama.cpp's assembled sampler chain.
406pub struct CommonSampler {
407 raw: NonNull<sys::common_shim_sampler>,
408}
409
410impl std::fmt::Debug for CommonSampler {
411 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412 f.debug_struct("CommonSampler")
413 .field("seed", &self.seed())
414 .finish_non_exhaustive()
415 }
416}
417
418// SAFETY: the handle owns a `common_sampler` that is not shared; every method
419// takes `&mut self` where it mutates.
420unsafe impl Send for CommonSampler {}
421
422impl Drop for CommonSampler {
423 fn drop(&mut self) {
424 // SAFETY: this runs once from `Drop` on a non-null handle, and nothing reads it
425 // afterwards.
426 unsafe { sys::common_shim_sampler_free(self.raw.as_ptr()) }
427 }
428}
429
430impl CommonSampler {
431 /// Assemble the chain for `model`.
432 ///
433 /// # Errors
434 ///
435 /// Returns [`CommonSamplerError::Failed`] if llama.cpp cannot build it —
436 /// most often a grammar that does not parse.
437 pub fn new(model: &LlamaModel, params: &mut CommonSamplerParams) -> Result<Self> {
438 let raw =
439 // SAFETY: the arguments are either scalars or pointers that outlive the call, and
440 // the library owns whatever it returns.
441 unsafe { sys::common_shim_sampler_init(model.model.as_ptr(), params.raw.as_ptr()) };
442 NonNull::new(raw)
443 .map(|raw| Self { raw })
444 .ok_or_else(|| CommonSamplerError::Failed(last_error()))
445 }
446
447 /// Sample a token from the logits at `idx`.
448 ///
449 /// `grammar_first` applies the grammar before the other samplers rather
450 /// than after. Upstream's default is `false`: sample first and only fall
451 /// back to grammar-constrained resampling if the pick is rejected, which is
452 /// much cheaper than filtering the whole vocabulary every step.
453 ///
454 /// # Errors
455 ///
456 /// Returns [`CommonSamplerError::Failed`] if llama.cpp throws.
457 pub fn sample(
458 &mut self,
459 ctx: &mut LlamaContext<'_>,
460 idx: i32,
461 grammar_first: bool,
462 ) -> Result<LlamaToken> {
463 let mut status = sys::LLAMA_SHIM_OK;
464 // SAFETY: the handle is non-null and kept alive by this borrow, and the remaining
465 // arguments are scalars the library validates itself.
466 let token = unsafe {
467 sys::common_shim_sampler_sample(
468 self.raw.as_ptr(),
469 ctx.context.as_ptr(),
470 idx,
471 grammar_first,
472 &raw mut status,
473 )
474 };
475 check_status(status)?;
476 Ok(LlamaToken(token))
477 }
478
479 /// Feed a token back into the sampler's history.
480 ///
481 /// `is_generated` marks a token the model produced, as opposed to one from
482 /// the prompt; penalties and the grammar treat the two differently.
483 pub fn accept(&mut self, token: LlamaToken, is_generated: bool) {
484 // SAFETY: the handle is non-null and kept alive by this borrow, and the remaining
485 // arguments are scalars the library validates itself.
486 unsafe { sys::common_shim_sampler_accept(self.raw.as_ptr(), token.0, is_generated) }
487 }
488
489 /// Validate a speculative draft and return the accepted prefix.
490 ///
491 /// This is the acceptance half of speculative decoding: given `draft`
492 /// tokens proposed by a drafter and a target-model forward pass covering
493 /// them, it returns every draft token the target agrees with, plus one
494 /// freshly sampled token at the first divergence. The result is therefore
495 /// never empty and never longer than `draft.len() + 1`.
496 ///
497 /// Accepted tokens are also fed to [`Self::accept`] internally, so the
498 /// caller must not do so again.
499 ///
500 /// # Errors
501 ///
502 /// Returns [`CommonSamplerError::Failed`] if llama.cpp throws.
503 pub fn sample_and_accept_n(
504 &mut self,
505 ctx: &mut LlamaContext<'_>,
506 draft: &[LlamaToken],
507 grammar_first: bool,
508 ) -> Result<Vec<LlamaToken>> {
509 let raw_draft: Vec<i32> = draft.iter().map(|t| t.0).collect();
510 // SAFETY: the closure hands the shim a buffer pointer together with its capacity,
511 // and the shim reports how much it needed; nothing is read back unless it
512 // reported success.
513 read_tokens(|out, cap, len| unsafe {
514 sys::common_shim_sampler_sample_and_accept_n(
515 self.raw.as_ptr(),
516 ctx.context.as_ptr(),
517 raw_draft.as_ptr(),
518 raw_draft.len(),
519 grammar_first,
520 out,
521 cap,
522 len,
523 )
524 })
525 }
526
527 /// Clear all history and grammar state, keeping the configuration.
528 pub fn reset(&mut self) {
529 // SAFETY: the handle is non-null and kept alive by this borrow, and the remaining
530 // arguments are scalars the library validates itself.
531 unsafe { sys::common_shim_sampler_reset(self.raw.as_ptr()) }
532 }
533
534 /// Deep-copy this sampler, state included.
535 ///
536 /// # Errors
537 ///
538 /// Returns [`CommonSamplerError::Failed`] if llama.cpp returns null.
539 pub fn try_clone(&self) -> Result<Self> {
540 // SAFETY: the handle is non-null and kept alive by this borrow, and the remaining
541 // arguments are scalars the library validates itself.
542 let raw = unsafe { sys::common_shim_sampler_clone(self.raw.as_ptr()) };
543 NonNull::new(raw)
544 .map(|raw| Self { raw })
545 .ok_or_else(|| CommonSamplerError::Failed(last_error()))
546 }
547
548 /// The seed actually in use, after any `LLAMA_DEFAULT_SEED` was resolved.
549 #[must_use]
550 pub fn seed(&self) -> u32 {
551 // SAFETY: the handle is non-null and kept alive by this borrow, and the remaining
552 // arguments are scalars the library validates itself.
553 unsafe { sys::common_shim_sampler_get_seed(self.raw.as_ptr()) }
554 }
555
556 /// The most recently sampled token.
557 #[must_use]
558 pub fn last(&self) -> LlamaToken {
559 // SAFETY: the handle is non-null and kept alive by this borrow, and the remaining
560 // arguments are scalars the library validates itself.
561 LlamaToken(unsafe { sys::common_shim_sampler_last(self.raw.as_ptr()) })
562 }
563
564 /// End the current reasoning block now, as if the budget had run out.
565 ///
566 /// Returns `false` when there is no budget sampler or it is not counting.
567 pub fn force_end_reasoning(&mut self) -> bool {
568 // SAFETY: the handle is non-null and kept alive by this borrow, and the remaining
569 // arguments are scalars the library validates itself.
570 unsafe { sys::common_shim_sampler_reasoning_budget_force(self.raw.as_ptr()) }
571 }
572
573 /// Describe the assembled chain, e.g. `"penalties -> top_k -> temp"`.
574 ///
575 /// # Errors
576 ///
577 /// Returns [`CommonSamplerError::Failed`] if llama.cpp throws.
578 pub fn describe(&self) -> Result<String> {
579 // SAFETY: the closure hands the shim a buffer pointer together with its capacity,
580 // and the shim reports how much it needed; nothing is read back unless it
581 // reported success.
582 read_string(|buf, len, expected| unsafe {
583 sys::common_shim_sampler_print(self.raw.as_ptr(), buf, len, expected)
584 })
585 }
586
587 /// Detokenize the last `n` sampled tokens.
588 ///
589 /// # Errors
590 ///
591 /// Returns [`CommonSamplerError::Failed`] if llama.cpp throws.
592 pub fn prev_str(&mut self, ctx: &mut LlamaContext<'_>, n: i32) -> Result<String> {
593 // SAFETY: the closure hands the shim a buffer pointer together with its capacity,
594 // and the shim reports how much it needed; nothing is read back unless it
595 // reported success.
596 read_string(|buf, len, expected| unsafe {
597 sys::common_shim_sampler_prev_str(
598 self.raw.as_ptr(),
599 ctx.context.as_ptr(),
600 n,
601 buf,
602 len,
603 expected,
604 )
605 })
606 }
607}
608
609// ─────────────────────────────────────────────────────────────────────────────
610// Reasoning budget
611// ─────────────────────────────────────────────────────────────────────────────
612
613/// Where a [`ReasoningBudget`] is in its state machine.
614#[derive(Debug, Clone, Copy, PartialEq, Eq)]
615pub enum ReasoningBudgetState {
616 /// Passing tokens through, watching for a start tag.
617 Idle,
618 /// Inside a reasoning block, counting down.
619 Counting,
620 /// Budget spent; emitting the forced closing sequence.
621 Forcing,
622 /// Budget spent, but finishing a partial UTF-8 sequence first.
623 WaitingUtf8,
624 /// Finished; passing everything through.
625 Done,
626}
627
628impl ReasoningBudgetState {
629 // Unsigned in C, signed on the wire — bridge in both directions.
630 #[allow(clippy::cast_possible_wrap)]
631 fn from_raw(raw: i32) -> Option<Self> {
632 if raw == sys::COMMON_SHIM_RBUDGET_IDLE as i32 {
633 Some(Self::Idle)
634 } else if raw == sys::COMMON_SHIM_RBUDGET_COUNTING as i32 {
635 Some(Self::Counting)
636 } else if raw == sys::COMMON_SHIM_RBUDGET_FORCING as i32 {
637 Some(Self::Forcing)
638 } else if raw == sys::COMMON_SHIM_RBUDGET_WAITING_UTF8 as i32 {
639 Some(Self::WaitingUtf8)
640 } else if raw == sys::COMMON_SHIM_RBUDGET_DONE as i32 {
641 Some(Self::Done)
642 } else {
643 None
644 }
645 }
646
647 #[allow(clippy::cast_possible_wrap)]
648 fn as_raw(self) -> i32 {
649 let raw = match self {
650 Self::Idle => sys::COMMON_SHIM_RBUDGET_IDLE,
651 Self::Counting => sys::COMMON_SHIM_RBUDGET_COUNTING,
652 Self::Forcing => sys::COMMON_SHIM_RBUDGET_FORCING,
653 Self::WaitingUtf8 => sys::COMMON_SHIM_RBUDGET_WAITING_UTF8,
654 Self::Done => sys::COMMON_SHIM_RBUDGET_DONE,
655 };
656 raw as i32
657 }
658}
659
660/// A sampler that caps how long a model may think.
661///
662/// Reasoning models emit an unbounded `<think>…</think>` block before their
663/// answer, and nothing in the model stops them running to the context limit.
664/// This watches for the start tag, counts down, and — when the budget is spent
665/// — forces the closing sequence token by token, masking everything else.
666///
667/// It is the piece llama.cpp pairs with a *lazy* grammar: the grammar leaves
668/// reasoning unconstrained by design, so something else has to bound it.
669///
670/// Build one with [`ReasoningBudget::new`] and add it to a
671/// [`LlamaSampler::chain`](crate::sampling::LlamaSampler::chain), or let
672/// [`CommonSampler`] assemble it for you via
673/// [`CommonSamplerParams::set_reasoning_budget`].
674#[derive(Debug)]
675pub struct ReasoningBudget {
676 sampler: crate::sampling::LlamaSampler,
677}
678
679impl ReasoningBudget {
680 /// Build a budget sampler.
681 ///
682 /// * `starts` — tokenized opening tags; any one arms the countdown.
683 /// * `ends` — tokenized closing tags; any one disarms it naturally.
684 /// * `forced` — emitted when the budget runs out, usually a short message
685 /// followed by a closing tag.
686 /// * `budget` — tokens allowed inside the block.
687 ///
688 /// # Errors
689 ///
690 /// Returns [`CommonSamplerError::Failed`] if llama.cpp returns null.
691 pub fn new(
692 model: &LlamaModel,
693 starts: &[Vec<LlamaToken>],
694 ends: &[Vec<LlamaToken>],
695 forced: &[LlamaToken],
696 budget: i32,
697 ) -> Result<Self> {
698 Self::with_initial_state(
699 model,
700 starts,
701 ends,
702 forced,
703 budget,
704 ReasoningBudgetState::Idle,
705 )
706 }
707
708 /// [`Self::new`] with an explicit starting state — use
709 /// [`ReasoningBudgetState::Counting`] when the prompt already opened a
710 /// reasoning block, which is what a template with `thinking_forced_open`
711 /// produces.
712 ///
713 /// # Errors
714 ///
715 /// Returns [`CommonSamplerError::Failed`] if llama.cpp returns null.
716 pub fn with_initial_state(
717 model: &LlamaModel,
718 starts: &[Vec<LlamaToken>],
719 ends: &[Vec<LlamaToken>],
720 forced: &[LlamaToken],
721 budget: i32,
722 initial_state: ReasoningBudgetState,
723 ) -> Result<Self> {
724 let (starts_flat, start_lens) = flatten(starts);
725 let (ends_flat, end_lens) = flatten(ends);
726 let forced_raw: Vec<i32> = forced.iter().map(|t| t.0).collect();
727
728 // SAFETY: the arguments are either scalars or pointers that outlive the call, and
729 // the library owns whatever it returns.
730 let raw = unsafe {
731 sys::common_shim_reasoning_budget_init(
732 model.get_vocab().vocab.as_ref(),
733 starts_flat.as_ptr(),
734 start_lens.as_ptr(),
735 start_lens.len(),
736 ends_flat.as_ptr(),
737 end_lens.as_ptr(),
738 end_lens.len(),
739 forced_raw.as_ptr(),
740 forced_raw.len(),
741 budget,
742 initial_state.as_raw(),
743 )
744 };
745 let ptr = NonNull::new(raw).ok_or_else(|| CommonSamplerError::Failed(last_error()))?;
746 // SAFETY: upstream returns a sampler the caller owns and frees with
747 // `llama_sampler_free`, which is what `LlamaSampler` does.
748 Ok(Self {
749 // SAFETY: `from_raw_ptr` is `unsafe` because it takes ownership; `ptr` is the
750 // non-null sampler the shim just created and nothing else holds it.
751 sampler: unsafe { crate::sampling::LlamaSampler::from_raw_ptr(ptr) },
752 })
753 }
754
755 /// Where the state machine currently is.
756 #[must_use]
757 pub fn state(&self) -> Option<ReasoningBudgetState> {
758 // SAFETY: the handle is non-null and kept alive by this borrow, and the remaining
759 // arguments are scalars the library validates itself.
760 let raw = unsafe {
761 sys::common_shim_reasoning_budget_get_state(self.sampler.as_ptr().cast_const())
762 };
763 ReasoningBudgetState::from_raw(raw)
764 }
765
766 /// Cut the reasoning block short now. Returns `false` if it was not
767 /// counting.
768 pub fn force_end(&mut self) -> bool {
769 // SAFETY: the handle is non-null and kept alive by this borrow, and the remaining
770 // arguments are scalars the library validates itself.
771 unsafe { sys::common_shim_reasoning_budget_force(self.sampler.as_ptr()) }
772 }
773
774 /// Take the underlying sampler, to add to a chain.
775 #[must_use]
776 pub fn into_sampler(self) -> crate::sampling::LlamaSampler {
777 self.sampler
778 }
779}
780
781#[cfg(test)]
782mod tests {
783 use super::*;
784
785 #[test]
786 fn params_start_from_llama_cpp_defaults() {
787 let params = CommonSamplerParams::new();
788 let s = params.scalars();
789 // Pinned against upstream's documented defaults; a bump that changes
790 // them silently changes output for every caller.
791 assert!((s.temp - 0.80).abs() < 1e-6, "temp = {}", s.temp);
792 assert_eq!(s.top_k, 40);
793 assert!((s.top_p - 0.95).abs() < 1e-6, "top_p = {}", s.top_p);
794 assert!((s.min_p - 0.05).abs() < 1e-6, "min_p = {}", s.min_p);
795 assert_eq!(s.mirostat, 0);
796 assert_eq!(s.reasoning_budget_tokens, -1, "budget disabled by default");
797 }
798
799 #[test]
800 fn scalars_round_trip() {
801 let mut params = CommonSamplerParams::new();
802 let mut s = params.scalars();
803 s.temp = 0.25;
804 s.top_k = 7;
805 s.reasoning_budget_tokens = 128;
806 params.set_scalars(&s);
807
808 let back = params.scalars();
809 assert!((back.temp - 0.25).abs() < 1e-6);
810 assert_eq!(back.top_k, 7);
811 assert_eq!(back.reasoning_budget_tokens, 128);
812 }
813
814 /// Setting one field must not disturb the others — the getter/setter pair
815 /// copies the whole struct, so a missed field would silently reset.
816 #[test]
817 fn setting_scalars_preserves_untouched_fields() {
818 let mut params = CommonSamplerParams::new();
819 let before = params.scalars();
820 let mut s = before;
821 s.top_k = 3;
822 params.set_scalars(&s);
823 let after = params.scalars();
824
825 assert_eq!(after.top_k, 3);
826 assert!((after.top_p - before.top_p).abs() < 1e-6);
827 assert!((after.dry_base - before.dry_base).abs() < 1e-6);
828 assert_eq!(after.penalty_last_n, before.penalty_last_n);
829 assert_eq!(after.seed, before.seed);
830 }
831
832 #[test]
833 fn sampler_type_names_match_upstream() {
834 assert_eq!(CommonSamplerType::TopK.name().unwrap(), "top_k");
835 assert_eq!(CommonSamplerType::TopP.name().unwrap(), "top_p");
836 assert_eq!(
837 CommonSamplerType::Temperature.name().unwrap(),
838 "temperature"
839 );
840 }
841
842 /// The discriminants are an ABI contract with `common_sampler_type`; if
843 /// they drift, `set_samplers` silently reorders the chain.
844 #[test]
845 fn sampler_type_discriminants_round_trip_through_names() {
846 for ty in [
847 CommonSamplerType::Dry,
848 CommonSamplerType::TopK,
849 CommonSamplerType::TopP,
850 CommonSamplerType::MinP,
851 CommonSamplerType::TypicalP,
852 CommonSamplerType::Temperature,
853 CommonSamplerType::Xtc,
854 CommonSamplerType::Infill,
855 CommonSamplerType::Penalties,
856 CommonSamplerType::TopNSigma,
857 CommonSamplerType::AdaptiveP,
858 ] {
859 let name = ty.name().unwrap();
860 let parsed = CommonSamplerType::from_names(&[&name]).unwrap();
861 assert_eq!(parsed, vec![ty as i32], "{name} did not round-trip");
862 }
863 }
864
865 #[test]
866 fn unknown_sampler_names_are_dropped() {
867 let parsed = CommonSamplerType::from_names(&["top_k", "not_a_sampler"]).unwrap();
868 assert_eq!(parsed, vec![CommonSamplerType::TopK as i32]);
869 }
870
871 #[test]
872 fn grammar_trigger_rejects_unknown_kind() {
873 let mut params = CommonSamplerParams::new();
874 assert!(matches!(
875 params.add_grammar_trigger("nonsense", "x", LlamaToken(-1)),
876 Err(CommonSamplerError::InvalidArg)
877 ));
878 }
879
880 #[test]
881 fn grammar_trigger_accepts_every_known_kind() {
882 let mut params = CommonSamplerParams::new();
883 for kind in ["token", "word", "pattern", "pattern_full"] {
884 params
885 .add_grammar_trigger(kind, "<tool_call>", LlamaToken(1))
886 .unwrap_or_else(|e| panic!("{kind} rejected: {e}"));
887 }
888 }
889
890 #[test]
891 fn interior_nul_is_rejected_not_truncated() {
892 let mut params = CommonSamplerParams::new();
893 assert!(matches!(
894 params.set_grammar("root ::= \0 \"a\"", GrammarSource::User, false),
895 Err(CommonSamplerError::Nul(_))
896 ));
897 assert!(matches!(
898 params.set_generation_prompt("a\0b"),
899 Err(CommonSamplerError::Nul(_))
900 ));
901 }
902
903 #[test]
904 fn flatten_produces_matching_data_and_lengths() {
905 let seqs = vec![
906 vec![LlamaToken(1), LlamaToken(2)],
907 vec![],
908 vec![LlamaToken(3)],
909 ];
910 let (data, lens) = flatten(&seqs);
911 assert_eq!(data, vec![1, 2, 3]);
912 assert_eq!(lens, vec![2, 0, 1]);
913 }
914}