kopitiam_runtime/generate.rs
1//! `prompt -> tokenizer -> forward passes with a KV cache -> sampled token
2//! ids -> detokenized text`: the one entry point that ties every other
3//! module in this crate together into something a CLI or UI actually
4//! calls.
5//!
6//! # Why streaming, not "wait for the whole completion"
7//!
8//! A 7B-parameter model doing one `f32` forward pass per token on CPU is
9//! slow — seconds per token is realistic, not a pathological case. A
10//! caller that gets nothing back until the entire completion is done has
11//! no way to distinguish "working" from "hung", which is a broken user
12//! experience even though the underlying computation is correct. So
13//! [`generate`] takes an `on_token` callback invoked once per newly
14//! produced token (its id and its decoded text), rather than only
15//! returning a final `String` — a caller that does not care about
16//! streaming can pass a no-op closure and use the return value alone.
17//!
18//! # [`generate`] vs [`generate_with_sampler`]
19//!
20//! [`generate`] always decodes greedily and is unchanged from Phase 1 —
21//! every existing caller (including `kopitiam-ai`'s `LocalAdapter`, which
22//! this crate cannot modify the call sites of) keeps compiling and
23//! behaving identically. [`generate_with_sampler`] is the Phase 2
24//! addition: the same pipeline, parameterized over any
25//! [`crate::sampling::Sampler`] — [`crate::sampling::GreedySampler`] or
26//! [`crate::sampling::StochasticSampler`] (temperature/top-k/top-p/min-p/
27//! repetition penalty — see that module's docs). `generate` is defined as
28//! `generate_with_sampler` called with a fresh `GreedySampler`, so the two
29//! entry points cannot silently drift apart into two different decoding
30//! loops.
31
32use kopitiam_core::Result;
33use kopitiam_tokenizer::Tokenizer;
34
35use crate::constraint::{ConstrainedSampler, ConstraintError, TokenConstraint};
36use crate::sampling::{GreedySampler, Sampler};
37use crate::traits::Model;
38
39/// Generation limits and stop conditions for [`generate`].
40#[derive(Debug, Clone)]
41pub struct GenerationConfig {
42 /// Hard cap on how many new tokens to produce, regardless of whether an
43 /// end-of-sequence token is ever sampled. Prevents an unbounded loop
44 /// when a model or prompt never naturally reaches its EOS token.
45 pub max_new_tokens: usize,
46 /// A token id that ends generation immediately when sampled — the
47 /// sampled token itself is *not* appended to the output or passed to
48 /// `on_token`, matching the usual convention that EOS is control
49 /// metadata, not part of the visible completion. Typically
50 /// `tokenizer.ggml.eos_token_id` from the model's GGUF metadata.
51 pub eos_token_id: Option<u32>,
52}
53
54impl Default for GenerationConfig {
55 fn default() -> Self {
56 Self { max_new_tokens: 256, eos_token_id: None }
57 }
58}
59
60/// Greedily generates a completion for `prompt`.
61///
62/// Runs one "prefill" forward pass over the whole encoded prompt, then
63/// repeatedly samples the highest-scoring next token
64/// ([`crate::sampling::GreedySampler`] — see that module's docs for why
65/// greedy is what this crate implements today), feeds it back through
66/// `model` one token at a time (each call appending to the same
67/// [`crate::kv_cache::KvCache`], obtained via
68/// [`Model::new_cache`]), and stops after `config.max_new_tokens` tokens
69/// or upon sampling `config.eos_token_id`, whichever comes first.
70///
71/// Calls `on_token(id, text)` once per generated token, in order, *before*
72/// that token is folded into the running completion — so a caller can
73/// render tokens as they arrive instead of waiting for the whole
74/// completion (see this module's docs). Returns the full completion text
75/// (every generated token, decoded together so multi-token Unicode
76/// characters join correctly — see [`kopitiam_tokenizer::Tokenizer::decode`]'s
77/// docs on why decoding token-by-token can split a character but decoding
78/// the whole sequence at the end never does).
79///
80/// # Errors
81///
82/// Propagates any [`kopitiam_core::Error`] from tokenizing, from a forward
83/// pass (including [`kopitiam_core::Error::IndexOutOfBounds`] if generation
84/// would exceed the model's context window — see
85/// [`crate::kv_cache::KvCache::append`]), or from decoding.
86pub fn generate<M: Model>(
87 model: &M,
88 tokenizer: &dyn Tokenizer,
89 prompt: &str,
90 config: &GenerationConfig,
91 on_token: impl FnMut(u32, &str),
92) -> Result<String> {
93 generate_with_sampler(model, tokenizer, prompt, config, &mut GreedySampler, on_token)
94}
95
96/// Identical to [`generate`] except the token-selection strategy is
97/// pluggable: any [`Sampler`] impl (greedy, or a
98/// [`crate::sampling::StochasticSampler`] configured for temperature/
99/// top-k/top-p/min-p/repetition-penalty sampling) drives which token is
100/// picked at every step, instead of always `argmax`. Every other detail —
101/// prefill, one-token-at-a-time KV-cache decoding, EOS handling, streaming
102/// via `on_token`, the returned completion text — is exactly [`generate`]'s
103/// behaviour, because `generate` is defined in terms of this function; see
104/// this module's docs for why that direction of composition (not the
105/// reverse) is what keeps the two entry points from drifting apart.
106///
107/// `sampler` is `&mut dyn Sampler` (a trait object) rather than a generic
108/// `S: Sampler` type parameter so a caller already holding a
109/// `Box<dyn Sampler>` or an `&mut dyn Sampler` (e.g. a long-lived session
110/// object that picks its sampler at runtime, per request) can pass it
111/// straight through without a wrapper; the erased-type call overhead is
112/// irrelevant next to one `f32` forward pass per token.
113///
114/// # Errors
115///
116/// Identical to [`generate`]'s.
117pub fn generate_with_sampler<M: Model>(
118 model: &M,
119 tokenizer: &dyn Tokenizer,
120 prompt: &str,
121 config: &GenerationConfig,
122 sampler: &mut dyn Sampler,
123 mut on_token: impl FnMut(u32, &str),
124) -> Result<String> {
125 let prompt_ids = tokenizer.encode(prompt)?;
126 let mut cache = model.new_cache();
127 let mut generated_ids: Vec<u32> = Vec::new();
128
129 if prompt_ids.is_empty() {
130 return Ok(String::new());
131 }
132
133 let logits = model.forward(&prompt_ids, &mut cache)?;
134 let mut next = sampler.sample(&last_row(&logits, model.vocab_size())?);
135
136 for _ in 0..config.max_new_tokens {
137 if config.eos_token_id == Some(next) {
138 break;
139 }
140 generated_ids.push(next);
141 let token_text = tokenizer.decode(&[next])?;
142 on_token(next, &token_text);
143
144 let logits = model.forward(&[next], &mut cache)?;
145 next = sampler.sample(&last_row(&logits, model.vocab_size())?);
146 }
147
148 tokenizer.decode(&generated_ids)
149}
150
151/// Either a normal runtime error, or the constraint leaving no valid token —
152/// the two failure modes [`generate_constrained`] can hit.
153///
154/// Kept crate-local (hand-rolled `Display`/`Error`, no `thiserror`) rather than
155/// bolted onto [`kopitiam_core::Error`]: this crate does not own that enum, and
156/// "the grammar constraint masked every token" is a decoding-policy fact that
157/// belongs next to the decoding code, not in the shared tensor-error vocabulary.
158/// A `?` on any inner runtime call folds into [`Runtime`](Self::Runtime); a
159/// `?` on [`ConstrainedSampler::try_sample`] folds into
160/// [`Constraint`](Self::Constraint).
161#[derive(Debug)]
162pub enum ConstrainedGenerateError {
163 /// A normal runtime failure (tokenize / forward pass / decode).
164 Runtime(kopitiam_core::Error),
165 /// The constraint masked every in-range token at some step — see
166 /// [`ConstraintError`]. Almost always means the constraint is missing an
167 /// escape hatch (e.g. it never allows EOS), not a broken model.
168 Constraint(ConstraintError),
169}
170
171impl std::fmt::Display for ConstrainedGenerateError {
172 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173 match self {
174 ConstrainedGenerateError::Runtime(e) => write!(f, "{e}"),
175 ConstrainedGenerateError::Constraint(e) => write!(f, "constrained decoding failed: {e}"),
176 }
177 }
178}
179
180impl std::error::Error for ConstrainedGenerateError {
181 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
182 match self {
183 ConstrainedGenerateError::Runtime(e) => Some(e),
184 ConstrainedGenerateError::Constraint(e) => Some(e),
185 }
186 }
187}
188
189impl From<kopitiam_core::Error> for ConstrainedGenerateError {
190 fn from(e: kopitiam_core::Error) -> Self {
191 ConstrainedGenerateError::Runtime(e)
192 }
193}
194
195impl From<ConstraintError> for ConstrainedGenerateError {
196 fn from(e: ConstraintError) -> Self {
197 ConstrainedGenerateError::Constraint(e)
198 }
199}
200
201/// Like [`generate_with_sampler`], but every step is **grammar-constrained**:
202/// the [`ConstrainedSampler`]'s [`TokenConstraint`] masks disallowed tokens to
203/// `-inf` *before* its inner sampler runs, so the model physically cannot emit
204/// a token the constraint forbids. This is the keystone that makes a small
205/// model reliably produce valid JSON / valid tool names / valid structure —
206/// see [`crate::constraint`] for the full rationale and provenance (AID-0045).
207///
208/// The decode loop is otherwise identical to [`generate_with_sampler`] —
209/// prefill, one-token-at-a-time KV-cache decoding, EOS handling, streaming via
210/// `on_token`, the returned completion text. The *only* differences are that
211/// token selection goes through [`ConstrainedSampler::try_sample`] (which
212/// applies the mask at the front of the sampling path) and that the extra
213/// failure mode — the constraint masking every token — is surfaced as
214/// [`ConstrainedGenerateError::Constraint`] rather than silently emitting
215/// rubbish.
216///
217/// `constrained` carries its own generated-token history for the constraint's
218/// [`crate::constraint::DecodeState`]; it should be freshly constructed (or
219/// [`ConstrainedSampler::reset`]) per decode so the constraint starts from an
220/// empty prefix. The constraint's vocabulary (if it is a
221/// [`crate::constraint::JsonStructure`]) must be sized to the same vocab as the
222/// model's logits row, or its mask can never allow the higher token ids.
223///
224/// # Errors
225///
226/// [`ConstrainedGenerateError::Runtime`] for any tokenize/forward/decode
227/// failure (exactly [`generate`]'s error set), or
228/// [`ConstrainedGenerateError::Constraint`] if the constraint leaves no valid
229/// token at some step. On a constraint error the partial completion is
230/// discarded — the loop stops and returns the error rather than a truncated
231/// string, so a caller cannot mistake a masked-out dead end for a finished
232/// completion.
233pub fn generate_constrained<M, C, S>(
234 model: &M,
235 tokenizer: &dyn Tokenizer,
236 prompt: &str,
237 config: &GenerationConfig,
238 constrained: &mut ConstrainedSampler<C, S>,
239 mut on_token: impl FnMut(u32, &str),
240) -> std::result::Result<String, ConstrainedGenerateError>
241where
242 M: Model,
243 C: TokenConstraint,
244 S: Sampler,
245{
246 let prompt_ids = tokenizer.encode(prompt)?;
247 let mut cache = model.new_cache();
248 let mut generated_ids: Vec<u32> = Vec::new();
249
250 if prompt_ids.is_empty() {
251 return Ok(String::new());
252 }
253
254 let logits = model.forward(&prompt_ids, &mut cache)?;
255 let mut next = constrained.try_sample(&last_row(&logits, model.vocab_size())?)?;
256
257 for _ in 0..config.max_new_tokens {
258 if config.eos_token_id == Some(next) {
259 break;
260 }
261 generated_ids.push(next);
262 let token_text = tokenizer.decode(&[next])?;
263 on_token(next, &token_text);
264
265 let logits = model.forward(&[next], &mut cache)?;
266 next = constrained.try_sample(&last_row(&logits, model.vocab_size())?)?;
267 }
268
269 Ok(tokenizer.decode(&generated_ids)?)
270}
271
272/// Extracts the last row (the newest token's logits) out of a
273/// `[seq, vocab_size]` logits tensor as a plain `Vec<f32>`, which is what
274/// [`crate::sampling::Sampler::sample`] operates on.
275fn last_row(logits: &kopitiam_tensor::Tensor, vocab_size: usize) -> Result<Vec<f32>> {
276 let data = logits.to_vec_f32()?;
277 let start = data.len() - vocab_size;
278 Ok(data[start..].to_vec())
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use crate::model::QwenModel;
285 use crate::test_support::synthetic_gguf::{build, write_temp_gguf, SyntheticModelSpec};
286 use kopitiam_tensor::Tensor;
287
288 /// A tiny full byte-level BPE tokenizer (256 base bytes plus two
289 /// merges: "ab" and "abc"), mirroring the fixture
290 /// `kopitiam_tokenizer::bpe`'s own tests use, so `generate`'s prompt
291 /// encoding step has a complete alphabet to work with. Its
292 /// vocab_size (258) is what the paired synthetic model below is sized
293 /// to, so every token id the tokenizer can produce is a valid
294 /// embedding row.
295 fn tiny_tokenizer() -> kopitiam_tokenizer::BpeTokenizer {
296 let mut vocab: Vec<Vec<u8>> = (0u16..=255).map(|b| vec![b as u8]).collect();
297 vocab.push(b"ab".to_vec()); // id 256
298 vocab.push(b"abc".to_vec()); // id 257
299 let merges = vec![(b"a".to_vec(), b"b".to_vec()), (b"ab".to_vec(), b"c".to_vec())];
300 kopitiam_tokenizer::BpeTokenizer::from_vocab_and_merges(vocab, merges).unwrap()
301 }
302
303 fn model_matching_tokenizer() -> QwenModel {
304 let spec = SyntheticModelSpec { vocab_size: 258, ..SyntheticModelSpec::default() };
305 let bytes = build(&spec);
306 let path = write_temp_gguf(&bytes, "generate-e2e");
307 let loaded = kopitiam_loader::load_model(&path).unwrap();
308 QwenModel::from_loaded_model(&loaded).unwrap()
309 }
310
311 #[test]
312 fn generate_produces_at_most_max_new_tokens_and_streams_every_one() {
313 let model = model_matching_tokenizer();
314 let tokenizer = tiny_tokenizer();
315 let config = GenerationConfig { max_new_tokens: 5, eos_token_id: None };
316
317 let mut streamed: Vec<u32> = Vec::new();
318 let text = generate(&model, &tokenizer, "abc", &config, |id, _text| streamed.push(id)).unwrap();
319
320 assert!(streamed.len() <= 5);
321 assert!(!streamed.is_empty(), "greedy decoding with no EOS configured must run the full budget");
322 assert_eq!(streamed.len(), 5);
323 // The returned text must be exactly the concatenation of every
324 // streamed token's own decode, not something else.
325 assert_eq!(text, tokenizer_decode_all(&tokenizer, &streamed));
326 }
327
328 fn tokenizer_decode_all(tokenizer: &kopitiam_tokenizer::BpeTokenizer, ids: &[u32]) -> String {
329 tokenizer.decode(ids).unwrap()
330 }
331
332 /// Greedy decoding from a fixed model and a fixed prompt is
333 /// deterministic (see `crate::model::tests::decoding_with_a_kv_cache_...`
334 /// for the underlying KV-cache property this relies on), so sampling
335 /// the very first generated token, then re-running with that token id
336 /// as `eos_token_id`, must stop generation before any token is
337 /// streamed or appended to the output.
338 #[test]
339 fn an_eos_token_stops_generation_before_it_is_emitted() {
340 let model = model_matching_tokenizer();
341 let tokenizer = tiny_tokenizer();
342 let unbounded = GenerationConfig { max_new_tokens: 1, eos_token_id: None };
343
344 let mut first_token = None;
345 generate(&model, &tokenizer, "abc", &unbounded, |id, _| first_token = Some(id)).unwrap();
346 let first_token = first_token.expect("greedy decoding must produce a first token");
347
348 let with_eos = GenerationConfig { max_new_tokens: 10, eos_token_id: Some(first_token) };
349 let mut streamed = Vec::new();
350 let text = generate(&model, &tokenizer, "abc", &with_eos, |id, _| streamed.push(id)).unwrap();
351
352 assert!(streamed.is_empty(), "the EOS token itself must never be streamed");
353 assert_eq!(text, "");
354 }
355
356 #[test]
357 fn an_empty_prompt_generates_nothing() {
358 let model = model_matching_tokenizer();
359 let tokenizer = tiny_tokenizer();
360 let config = GenerationConfig { max_new_tokens: 5, eos_token_id: None };
361 let mut calls = 0;
362 let text = generate(&model, &tokenizer, "", &config, |_, _| calls += 1).unwrap();
363 assert_eq!(calls, 0);
364 assert_eq!(text, "");
365 }
366
367 #[test]
368 fn last_row_extracts_the_final_positions_logits() {
369 let logits = Tensor::from_f32(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], [3, 2]).unwrap();
370 assert_eq!(last_row(&logits, 2).unwrap(), vec![5.0, 6.0]);
371 }
372
373 #[test]
374 fn zero_max_new_tokens_generates_nothing_but_still_runs_prefill() {
375 let model = model_matching_tokenizer();
376 let tokenizer = tiny_tokenizer();
377 let config = GenerationConfig { max_new_tokens: 0, eos_token_id: None };
378 let mut calls = 0;
379 let text = generate(&model, &tokenizer, "abc", &config, |_, _| calls += 1).unwrap();
380 assert_eq!(calls, 0);
381 assert_eq!(text, "");
382 }
383
384 // -- generate_with_sampler --
385
386 #[test]
387 fn generate_with_a_greedy_sampler_matches_generates_own_output() {
388 // `generate` is defined as `generate_with_sampler` plus a fresh
389 // `GreedySampler` -- this pins that equivalence at the public API
390 // level, not just by reading the implementation.
391 use crate::sampling::GreedySampler;
392 let model = model_matching_tokenizer();
393 let tokenizer = tiny_tokenizer();
394 let config = GenerationConfig { max_new_tokens: 6, eos_token_id: None };
395
396 let via_generate = generate(&model, &tokenizer, "abc", &config, |_, _| {}).unwrap();
397 let via_sampler =
398 generate_with_sampler(&model, &tokenizer, "abc", &config, &mut GreedySampler, |_, _| {}).unwrap();
399 assert_eq!(via_generate, via_sampler);
400 }
401
402 #[test]
403 fn generate_with_sampler_and_a_fixed_seed_is_reproducible_end_to_end() {
404 use crate::sampling::{SamplingConfig, StochasticSampler};
405 let model = model_matching_tokenizer();
406 let tokenizer = tiny_tokenizer();
407 let config = GenerationConfig { max_new_tokens: 8, eos_token_id: None };
408
409 let run = || {
410 let mut sampler = StochasticSampler::new(SamplingConfig {
411 temperature: 1.0,
412 top_k: Some(5),
413 seed: 7,
414 ..SamplingConfig::default()
415 });
416 generate_with_sampler(&model, &tokenizer, "abc", &config, &mut sampler, |_, _| {}).unwrap()
417 };
418
419 assert_eq!(run(), run(), "the same seed must reproduce the exact same completion end to end");
420 }
421
422 // -- generate_constrained: the keystone, end to end --
423
424 #[test]
425 fn constrained_generation_only_ever_emits_allowed_tokens_end_to_end() {
426 // The full loop must mask BEFORE sampling: the greedy inner sampler,
427 // run over a real synthetic model, must never stream a token outside
428 // the allowed set, no matter what the model's raw argmax wanted.
429 use crate::constraint::{AllowedTokens, ConstrainedSampler};
430 use crate::sampling::GreedySampler;
431
432 let model = model_matching_tokenizer();
433 let tokenizer = tiny_tokenizer();
434 let config = GenerationConfig { max_new_tokens: 12, eos_token_id: None };
435
436 let allowed: Vec<u32> = vec![10, 20, 30, 40];
437 let constraint = AllowedTokens::new(allowed.clone()).unwrap();
438 let mut sampler = ConstrainedSampler::new(constraint, GreedySampler);
439
440 let mut streamed: Vec<u32> = Vec::new();
441 let text =
442 generate_constrained(&model, &tokenizer, "abc", &config, &mut sampler, |id, _| streamed.push(id)).unwrap();
443
444 assert_eq!(streamed.len(), 12, "no EOS configured, so the full budget must run");
445 for id in &streamed {
446 assert!(allowed.contains(id), "constrained decode streamed disallowed token {id}");
447 }
448 // The returned text is exactly the decode of the (all-allowed) ids.
449 assert_eq!(text, tokenizer.decode(&streamed).unwrap());
450 }
451
452 #[test]
453 fn constrained_generation_surfaces_a_dead_constraint_as_an_error() {
454 // A constraint whose only allowed id is out of the model's vocab range
455 // masks everything -> an honest Constraint error, not a panic and not a
456 // truncated string.
457 use crate::constraint::{AllowedTokens, ConstrainedSampler, ConstraintError};
458 use crate::sampling::GreedySampler;
459
460 let model = model_matching_tokenizer();
461 let tokenizer = tiny_tokenizer();
462 let config = GenerationConfig { max_new_tokens: 4, eos_token_id: None };
463
464 // vocab_size is 258; id 9999 can never be in range.
465 let constraint = AllowedTokens::new([9999]).unwrap();
466 let mut sampler = ConstrainedSampler::new(constraint, GreedySampler);
467
468 let err = generate_constrained(&model, &tokenizer, "abc", &config, &mut sampler, |_, _| {}).unwrap_err();
469 assert!(matches!(err, ConstrainedGenerateError::Constraint(ConstraintError::NoTokenAllowed)));
470 }
471
472 #[test]
473 fn generate_with_sampler_temperature_zero_matches_greedy_generate_end_to_end() {
474 use crate::sampling::{SamplingConfig, StochasticSampler};
475 let model = model_matching_tokenizer();
476 let tokenizer = tiny_tokenizer();
477 let config = GenerationConfig { max_new_tokens: 6, eos_token_id: None };
478
479 let greedy_text = generate(&model, &tokenizer, "abc", &config, |_, _| {}).unwrap();
480
481 let mut sampler = StochasticSampler::new(SamplingConfig { temperature: 0.0, ..SamplingConfig::default() });
482 let stochastic_text =
483 generate_with_sampler(&model, &tokenizer, "abc", &config, &mut sampler, |_, _| {}).unwrap();
484
485 assert_eq!(
486 greedy_text, stochastic_text,
487 "temperature=0.0 must reproduce plain greedy decoding through the full generate loop, not just per-call"
488 );
489 }
490}