Skip to main content

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}