Skip to main content

maolan_generate/acestep/
lm.rs

1//! ACE-Step 1.5 5Hz LM planner: a Qwen3-0.6B causal LM (`tie_word_embeddings =
2//! true`) that turns a caption plus musical conditions into audio semantic
3//! codes.
4//!
5//! Prompt contract (ACE-Step 1.5):
6//!
7//! - The chain-of-thought block is a `<think>...</think>` YAML block with the
8//!   keys `bpm`, `caption`, `duration`, `keyscale`, `language`,
9//!   `timesignature` in that (sorted) order; only present keys are emitted.
10//!   Integral `bpm`/`duration` values print as integers, `language` is always
11//!   `unknown` (the training value for instrumental tracks), and
12//!   `timesignature` is kept verbatim ("4/4", "6/8", ...).
13//! - The full prompt embeds that block in the Qwen chat template with the
14//!   `# Lyric` section set to `[Instrumental]` (the official placeholder for
15//!   instrumental tracks); see [`build_codes_prompt`].
16//! - Generation is autoregressive with the official phase-2 knobs:
17//!   temperature 0.85, top-p 0.9, top-k disabled, and CFG 2.0 against an
18//!   empty unconditional prompt (see [`build_uncond_codes_prompt`]). Sampling
19//!   is restricted to the special-token range (>= `<|im_end|>`) and stops at
20//!   `<|im_end|>`. Audio codes are `<|audio_code_N|>` tokens with N in
21//!   `0..=63999`, produced at roughly 5 tokens per second of audio.
22
23use std::collections::HashMap;
24use std::path::Path;
25
26use anyhow::{Context, Result};
27use burn::prelude::Backend;
28use burn::tensor::{DType, Int, Tensor, TensorData};
29use burn_store::ModuleStore;
30use rand::rngs::SmallRng;
31use rand::{RngExt, SeedableRng};
32
33use super::qwen3::{Qwen3Config, Qwen3Model};
34
35/// `<|im_end|>` — end-of-generation stop token for the LM planner.
36pub const IM_END_ID: u32 = 151_645;
37/// `<|endoftext|>` — base Qwen3 EOS; not the planner's stop token.
38pub const ENDOFTEXT_ID: u32 = 151_643;
39/// Highest valid audio code index (`<|audio_code_63999|>`).
40pub const MAX_AUDIO_CODE: u32 = 63_999;
41/// Audio semantic codes per second of audio (the "5Hz" planner rate).
42pub const AUDIO_CODES_PER_SECOND: usize = 5;
43/// Default sampling temperature from the ACE-Step 1.5 generation config.
44pub const DEFAULT_TEMPERATURE: f32 = 0.85;
45
46/// Number of audio codes the planner should emit for `duration_s` seconds.
47pub fn code_count_for_duration(duration_s: usize) -> usize {
48    duration_s * AUDIO_CODES_PER_SECOND
49}
50
51/// Chain-of-thought `<think>` YAML block with sorted keys.
52///
53/// `bpm` prints as an integer when integral, `duration_s` always does, and a
54/// trailing `/4` is stripped from `time_signature`.
55pub fn build_cot_block(
56    caption: &str,
57    bpm: Option<f32>,
58    key_scale: Option<&str>,
59    time_signature: Option<&str>,
60    duration_s: usize,
61) -> String {
62    let mut lines = Vec::new();
63    if let Some(bpm) = bpm {
64        if bpm.fract() == 0.0 {
65            lines.push(format!("bpm: {}", bpm as i64));
66        } else {
67            lines.push(format!("bpm: {bpm}"));
68        }
69    }
70    lines.push(format!("caption: {caption}"));
71    lines.push(format!("duration: {duration_s}"));
72    if let Some(key_scale) = key_scale {
73        lines.push(format!("keyscale: {key_scale}"));
74    }
75    lines.push("language: unknown".to_string());
76    if let Some(time_signature) = time_signature {
77        lines.push(format!("timesignature: {time_signature}"));
78    }
79    format!("<think>\n{}\n</think>", lines.join("\n"))
80}
81
82/// Unconditional prompt for CFG during code generation: empty user turn and
83/// empty `<think>` block (two inner newlines, matching how Qwen's chat
84/// template renders empty reasoning).
85pub fn build_uncond_codes_prompt() -> String {
86    "<|im_start|>system\n\
87     # Instruction\n\
88     Generate audio semantic tokens based on the given conditions:\n\
89     \n\
90     <|im_end|>\n\
91     <|im_start|>user\n\
92     <|im_end|>\n\
93     <|im_start|>assistant\n\
94     <think>\n\
95     \n\
96     </think>\n\
97     \n"
98    .to_string()
99}
100
101/// Full LM-planner prompt: Qwen chat template, `# Lyric` section set to
102/// `[Instrumental]` (the official placeholder for instrumental tracks —
103/// empty lyrics are out-of-distribution), assistant turn pre-filled with
104/// the CoT block.
105pub fn build_codes_prompt(caption: &str, cot_block: &str) -> String {
106    format!(
107        "<|im_start|>system\n\
108         # Instruction\n\
109         Generate audio semantic tokens based on the given conditions:\n\
110         \n\
111         <|im_end|>\n\
112         <|im_start|>user\n\
113         # Caption\n\
114         {caption}\n\
115         \n\
116         # Lyric\n\
117         [Instrumental]\n\
118         <|im_end|>\n\
119         <|im_start|>assistant\n\
120         {cot_block}\n\
121         \n"
122    )
123}
124
125/// Bidirectional map between `<|audio_code_N|>` token ids and code indices.
126///
127/// Parsed from the `added_tokens` array of a HuggingFace `tokenizer.json`
128/// (tokie does not enumerate added tokens, so this uses `serde_json`
129/// directly — robust against tokie API changes).
130#[derive(Clone, Debug, Default)]
131pub struct AudioCodeVocab {
132    code_to_token: HashMap<u32, u32>,
133    token_to_code: HashMap<u32, u32>,
134}
135
136impl AudioCodeVocab {
137    /// `<|im_end|>` — generation stop token.
138    pub const IM_END_ID: u32 = IM_END_ID;
139    /// `<|endoftext|>`.
140    pub const ENDOFTEXT_ID: u32 = ENDOFTEXT_ID;
141
142    pub fn from_tokenizer_json(path: &Path) -> Result<Self> {
143        let text = std::fs::read_to_string(path)
144            .with_context(|| format!("failed to read tokenizer from {}", path.display()))?;
145        let data: serde_json::Value = serde_json::from_str(&text)
146            .with_context(|| format!("failed to parse tokenizer from {}", path.display()))?;
147        let mut vocab = Self::default();
148        if let Some(added) = data.get("added_tokens").and_then(|v| v.as_array()) {
149            for entry in added {
150                let Some(content) = entry.get("content").and_then(|c| c.as_str()) else {
151                    continue;
152                };
153                let Some(id) = entry.get("id").and_then(|i| i.as_u64()) else {
154                    continue;
155                };
156                let Some(code) = parse_audio_code_content(content) else {
157                    continue;
158                };
159                let id = u32::try_from(id)
160                    .with_context(|| format!("token id out of range for {content}"))?;
161                vocab.code_to_token.insert(code, id);
162                vocab.token_to_code.insert(id, code);
163            }
164        }
165        Ok(vocab)
166    }
167
168    /// Token id of `<|audio_code_N|>`, or `None` if unknown.
169    pub fn code_token_id(&self, n: u32) -> Option<u32> {
170        self.code_to_token.get(&n).copied()
171    }
172
173    /// Code index N for the token id of `<|audio_code_N|>`, or `None` when
174    /// `id` is not an audio-code token.
175    pub fn token_id_to_code(&self, id: u32) -> Option<u32> {
176        self.token_to_code.get(&id).copied()
177    }
178
179    pub fn len(&self) -> usize {
180        self.code_to_token.len()
181    }
182
183    pub fn is_empty(&self) -> bool {
184        self.code_to_token.is_empty()
185    }
186}
187
188fn parse_audio_code_content(content: &str) -> Option<u32> {
189    content
190        .strip_prefix("<|audio_code_")?
191        .strip_suffix("|>")?
192        .parse()
193        .ok()
194}
195
196/// Sampling knobs for [`AceStepLm::generate_codes`].
197#[derive(Clone, Copy, Debug)]
198pub struct SamplingConfig {
199    pub max_new_tokens: usize,
200    pub temperature: f32,
201    /// Nucleus sampling threshold (0.9 officially; 1.0 disables).
202    pub top_p: f32,
203    /// Top-k limit; 0 disables (official default).
204    pub top_k: usize,
205    /// Classifier-free guidance scale for code generation (2.0 officially;
206    /// 1.0 disables the unconditional branch).
207    pub cfg_scale: f32,
208    pub seed: u64,
209}
210
211impl SamplingConfig {
212    /// Official ACE-Step 1.5 phase-2 defaults: temperature 0.85, top-p 0.9,
213    /// top-k disabled, CFG 2.0.
214    pub fn new(max_new_tokens: usize, seed: u64) -> Self {
215        Self {
216            max_new_tokens,
217            temperature: DEFAULT_TEMPERATURE,
218            top_p: 0.9,
219            top_k: 0,
220            cfg_scale: 2.0,
221            seed,
222        }
223    }
224}
225
226/// Qwen3ForCausalLM with a tied lm_head: logits are
227/// `hidden @ embed_tokens.weight^T`.
228#[derive(Debug)]
229pub struct AceStepLm<B: Backend> {
230    pub model: Qwen3Model<B>,
231}
232
233impl<B: Backend> AceStepLm<B> {
234    pub fn new(config: &Qwen3Config, device: &B::Device) -> Self {
235        Self {
236            model: Qwen3Model::new(config, device),
237        }
238    }
239
240    pub fn from_burnpack(config: &Qwen3Config, path: &Path, device: &B::Device) -> Result<Self> {
241        Ok(Self {
242            model: Qwen3Model::from_burnpack(config, path, device)?,
243        })
244    }
245
246    /// Load weights from a burnpack file, casting every tensor to the
247    /// backend's native float element (e.g. loading the f32 export into an
248    /// f16 planner — halves planner VRAM on wgpu).
249    pub fn from_burnpack_cast(
250        config: &Qwen3Config,
251        path: &Path,
252        device: &B::Device,
253    ) -> Result<Self> {
254        let mut model = Self::new(config, device);
255        let snapshots = burn_store::BurnpackStore::from_file(path)
256            .zero_copy(true)
257            .get_all_snapshots()
258            .with_context(|| format!("failed to read snapshots from {}", path.display()))?
259            .clone();
260        let mut converted = Vec::with_capacity(snapshots.len());
261        for snapshot in snapshots.values() {
262            let data = snapshot
263                .to_data()
264                .map_err(|e| anyhow::anyhow!("failed to decode {}: {e:?}", snapshot.full_path()))?
265                .convert::<B::FloatElem>();
266            converted.push(burn_store::TensorSnapshot::from_data(
267                data,
268                snapshot.path_stack.clone().unwrap_or_default(),
269                snapshot.container_stack.clone().unwrap_or_default(),
270                snapshot.tensor_id.unwrap_or_default(),
271            ));
272        }
273        let result =
274            burn_store::ModuleSnapshot::apply(&mut model.model, converted, None, None, false);
275        if !result.is_success() {
276            anyhow::bail!(
277                "failed to apply LM weights from {}: {result}",
278                path.display()
279            );
280        }
281        Ok(model)
282    }
283
284    /// Autoregressively generate audio code indices.
285    ///
286    /// Prefills `prompt_token_ids`, then samples one token per step. When
287    /// `sampling.cfg_scale > 1`, the unconditional prompt (empty user turn,
288    /// empty `<think>`) is prefilled alongside and logits combine as
289    /// `uncond + cfg * (cond - uncond)`, matching the official phase-2
290    /// inference. Sampling is restricted to ids >= `<|im_end|>` (the special
291    /// tokens range containing the audio codes) with temperature + top-p
292    /// (+ optional top-k), and stops at `<|im_end|>` or after
293    /// `sampling.max_new_tokens` steps; sampled tokens that are not
294    /// `<|audio_code_N|>` are fed back to both branches but not emitted.
295    /// `progress`, when given, is called each step with
296    /// `(steps_done, max_new_tokens)`.
297    pub fn generate_codes(
298        &self,
299        prompt_token_ids: &[u32],
300        uncond_token_ids: Option<&[u32]>,
301        vocab: &AudioCodeVocab,
302        sampling: &SamplingConfig,
303        mut progress: Option<&mut dyn FnMut(usize, usize)>,
304    ) -> Vec<u32> {
305        let device = self.model.embedding_weight().device();
306        let mut rng = SmallRng::seed_from_u64(sampling.seed);
307        let mut codes = Vec::new();
308        let use_cfg = sampling.cfg_scale > 1.0 && uncond_token_ids.is_some();
309
310        let mut cache = self.model.new_cache();
311        let prompt_len = prompt_token_ids.len();
312        let hidden =
313            self.model
314                .forward_cached(ids_tensor::<B>(prompt_token_ids, &device), &mut cache, 0);
315        let mut logits = self.last_position_logits(hidden);
316
317        let (mut uncond_cache, mut uncond_logits, uncond_len) = if use_cfg {
318            let uncond_ids = uncond_token_ids.expect("cfg requires uncond ids");
319            let mut cache = self.model.new_cache();
320            let hidden =
321                self.model
322                    .forward_cached(ids_tensor::<B>(uncond_ids, &device), &mut cache, 0);
323            let logits = self.last_position_logits(hidden);
324            (Some(cache), Some(logits), uncond_ids.len())
325        } else {
326            (None, None, 0)
327        };
328
329        let mut completed = sampling.max_new_tokens;
330        // Official phase-2 constraint: only `<|im_end|>` and audio-code tokens
331        // are samplable; everything between them is masked out.
332        let code_base = vocab.code_token_id(0);
333        for step in 0..sampling.max_new_tokens {
334            if let Some(cb) = progress.as_mut() {
335                cb(step, sampling.max_new_tokens);
336            }
337            let combined = match (&logits, &uncond_logits) {
338                (cond, Some(uncond)) => cond
339                    .iter()
340                    .zip(uncond.iter())
341                    .map(|(c, u)| u + sampling.cfg_scale * (c - u))
342                    .collect(),
343                (cond, None) => cond.clone(),
344            };
345            let token = sample_codes_token(&combined, code_base, sampling, &mut rng);
346            if token == IM_END_ID {
347                completed = step;
348                break;
349            }
350            if let Some(code) = vocab.token_id_to_code(token) {
351                codes.push(code);
352            }
353            let hidden = self.model.forward_cached(
354                ids_tensor::<B>(&[token], &device),
355                &mut cache,
356                prompt_len + step,
357            );
358            logits = self.last_position_logits(hidden);
359            if let (Some(cache), Some(uncond)) = (&mut uncond_cache, &mut uncond_logits) {
360                let hidden = self.model.forward_cached(
361                    ids_tensor::<B>(&[token], &device),
362                    cache,
363                    uncond_len + step,
364                );
365                *uncond = self.last_position_logits(hidden);
366            }
367        }
368        if let Some(cb) = progress.as_mut() {
369            cb(completed, sampling.max_new_tokens);
370        }
371        codes
372    }
373
374    /// Tied lm_head logits at the last sequence position, as a flat f32 vec.
375    fn last_position_logits(&self, hidden: Tensor<B, 3>) -> Vec<f32> {
376        let [batch, seq_len, hidden_size] = hidden.dims();
377        let last = hidden
378            .slice([0..batch, seq_len - 1..seq_len, 0..hidden_size])
379            .reshape([batch, hidden_size]);
380        let weight = self.model.embedding_weight();
381        let logits = last.matmul(weight.swap_dims(0, 1));
382        logits
383            .cast(DType::F32)
384            .to_data()
385            .to_vec::<f32>()
386            .expect("lm_head logits should materialize as f32")
387    }
388}
389
390fn ids_tensor<B: Backend>(ids: &[u32], device: &B::Device) -> Tensor<B, 2, Int> {
391    let data: Vec<i64> = ids.iter().map(|&t| i64::from(t)).collect();
392    let len = data.len();
393    Tensor::<B, 2, Int>::from_data(TensorData::new(data, [1, len]), device)
394}
395
396/// Restricted sampling over a logit vector for code generation: only
397/// `<|im_end|>` and audio-code tokens (`code_base..`) are eligible, matching
398/// the official phase-2 FSM mask (`lc[im_end+1 .. code_base] = -inf`).
399/// Temperature scales the logits; `top_p < 1` applies nucleus filtering;
400/// `top_k > 0` applies an additional top-k cut. Near-zero temperature or
401/// `top_k == 1` is argmax.
402fn sample_codes_token(
403    logits: &[f32],
404    code_base: Option<u32>,
405    sampling: &SamplingConfig,
406    rng: &mut SmallRng,
407) -> u32 {
408    let code_base = code_base.unwrap_or(IM_END_ID + 1) as usize;
409    let mut indices: Vec<usize> = Vec::with_capacity(logits.len() - code_base + 1);
410    if logits.len() > IM_END_ID as usize {
411        indices.push(IM_END_ID as usize);
412        indices.extend(code_base..logits.len());
413    } else {
414        // Tiny test vocabs fall back to the full range.
415        indices.extend(0..logits.len());
416    }
417    indices.sort_by(|&a, &b| logits[b].total_cmp(&logits[a]));
418    if sampling.temperature < 1e-3 || sampling.top_k == 1 {
419        return indices[0] as u32;
420    }
421    if sampling.top_k > 0 {
422        indices.truncate(sampling.top_k.min(indices.len()).max(1));
423    }
424
425    let temperature = sampling.temperature.max(f32::EPSILON);
426    let max_scaled = logits[indices[0]] / temperature;
427    let mut weights: Vec<f32> = indices
428        .iter()
429        .map(|&i| (logits[i] / temperature - max_scaled).exp())
430        .collect();
431    let total: f32 = weights.iter().sum();
432
433    if sampling.top_p < 1.0 {
434        let mut keep = weights.len();
435        let mut acc = 0.0;
436        for (i, &w) in weights.iter().enumerate() {
437            acc += w;
438            if acc / total >= sampling.top_p {
439                keep = i + 1;
440                break;
441            }
442        }
443        weights.truncate(keep.max(1));
444        indices.truncate(keep.max(1));
445    }
446
447    let total: f32 = weights.iter().sum();
448    let mut draw = rng.random::<f32>() * total;
449    for (pos, &w) in weights.iter().enumerate() {
450        draw -= w;
451        if draw <= 0.0 {
452            return indices[pos] as u32;
453        }
454    }
455    *indices.last().expect("candidate set is non-empty") as u32
456}
457
458/// BPE-encode a planner prompt with a HuggingFace `tokenizer.json`.
459///
460/// `add_special_tokens` is `false`: the template already spells out every
461/// `<|...|>` marker as text. tokie matches added tokens (including special
462/// ones) against the input before BPE — exactly like HuggingFace — so the
463/// `<|im_start|>` / `<|im_end|>` / `<|audio_code_N|>` strings in the prompt
464/// map to their special ids directly and no manual pre-pass is needed.
465pub fn tokenize_prompt(tokenizer_json_path: &Path, text: &str) -> Result<Vec<u32>> {
466    let tokenizer = tokie::Tokenizer::from_json(tokenizer_json_path).map_err(|e| {
467        anyhow::anyhow!(
468            "failed to load tokenizer from {}: {e}",
469            tokenizer_json_path.display()
470        )
471    })?;
472    Ok(tokenizer.encode(text, false).ids)
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use burn::backend::NdArray;
479
480    type TestBackend = NdArray<f32>;
481
482    fn testdata(name: &str) -> std::path::PathBuf {
483        Path::new(env!("CARGO_MANIFEST_DIR"))
484            .join("src/acestep/testdata")
485            .join(name)
486    }
487
488    #[test]
489    fn cot_block_all_fields() {
490        let block = build_cot_block(
491            "epic orchestral score",
492            Some(120.0),
493            Some("C major"),
494            Some("4/4"),
495            30,
496        );
497        assert_eq!(
498            block,
499            "<think>\n\
500             bpm: 120\n\
501             caption: epic orchestral score\n\
502             duration: 30\n\
503             keyscale: C major\n\
504             language: unknown\n\
505             timesignature: 4/4\n\
506             </think>"
507        );
508    }
509
510    #[test]
511    fn cot_block_missing_bpm() {
512        let block = build_cot_block("lofi beat", None, Some("A minor"), Some("4/4"), 10);
513        assert!(!block.contains("bpm:"));
514        assert_eq!(
515            block,
516            "<think>\n\
517             caption: lofi beat\n\
518             duration: 10\n\
519             keyscale: A minor\n\
520             language: unknown\n\
521             timesignature: 4/4\n\
522             </think>"
523        );
524    }
525
526    #[test]
527    fn cot_block_keeps_time_signature_verbatim() {
528        let block = build_cot_block("piano", None, None, Some("4/4"), 5);
529        assert!(block.contains("timesignature: 4/4\n"));
530        let block = build_cot_block("waltz", None, None, Some("6/8"), 5);
531        assert!(block.contains("timesignature: 6/8"));
532        let block = build_cot_block("march", Some(128.5), None, Some("3/4"), 5);
533        assert!(block.contains("timesignature: 3/4\n"));
534        assert!(block.contains("bpm: 128.5"));
535    }
536
537    #[test]
538    fn codes_prompt_byte_exact() {
539        let cot = "<think>\ncaption: calm piano\nlanguage: instrumental\n</think>";
540        let prompt = build_codes_prompt("calm piano", cot);
541        let expected = "<|im_start|>system\n\
542             # Instruction\n\
543             Generate audio semantic tokens based on the given conditions:\n\
544             \n\
545             <|im_end|>\n\
546             <|im_start|>user\n\
547             # Caption\n\
548             calm piano\n\
549             \n\
550             # Lyric\n\
551             [Instrumental]\n\
552             <|im_end|>\n\
553             <|im_start|>assistant\n\
554             <think>\n\
555             caption: calm piano\n\
556             language: instrumental\n\
557             </think>\n\
558             \n";
559        assert_eq!(prompt, expected);
560    }
561
562    #[test]
563    fn audio_code_vocab_from_tokenizer_json() {
564        let vocab = AudioCodeVocab::from_tokenizer_json(&testdata("lm_tokenizer_min.json"))
565            .expect("should parse minimal tokenizer.json");
566        assert_eq!(vocab.len(), 3);
567        assert!(!vocab.is_empty());
568        assert_eq!(vocab.code_token_id(0), Some(200));
569        assert_eq!(vocab.code_token_id(1), Some(201));
570        assert_eq!(vocab.code_token_id(42), Some(250));
571        assert_eq!(vocab.code_token_id(7), None);
572        assert_eq!(vocab.token_id_to_code(201), Some(1));
573        assert_eq!(vocab.token_id_to_code(101), None);
574        assert_eq!(AudioCodeVocab::IM_END_ID, 151_645);
575        assert_eq!(AudioCodeVocab::ENDOFTEXT_ID, 151_643);
576    }
577
578    #[test]
579    fn tokenize_prompt_maps_special_token_strings() {
580        let path = testdata("lm_tokenizer_min.json");
581        let ids = tokenize_prompt(&path, "<|im_start|>a<|im_end|>")
582            .expect("should tokenize with the minimal fixture");
583        assert_eq!(ids, vec![101, 0, 102]);
584    }
585
586    fn tiny_lm() -> AceStepLm<TestBackend> {
587        let config = Qwen3Config {
588            hidden_size: 32,
589            intermediate_size: 64,
590            num_hidden_layers: 2,
591            num_attention_heads: 4,
592            num_key_value_heads: 2,
593            head_dim: 8,
594            rms_norm_eps: 1e-6,
595            rope_theta: 1_000_000.0,
596            vocab_size: 128,
597            max_position_embeddings: 512,
598            tie_word_embeddings: true,
599        };
600        let device = Default::default();
601        AceStepLm::new(&config, &device)
602    }
603
604    fn tiny_vocab() -> AudioCodeVocab {
605        // Codes 0..8 live at token ids 1..9 of the 128-token toy vocab.
606        let mut vocab = AudioCodeVocab::default();
607        for code in 0..8u32 {
608            vocab.code_to_token.insert(code, code + 1);
609            vocab.token_to_code.insert(code + 1, code);
610        }
611        vocab
612    }
613
614    #[test]
615    fn generate_codes_deterministic_and_bounded() {
616        let lm = tiny_lm();
617        let vocab = tiny_vocab();
618        let prompt: Vec<u32> = vec![10, 11, 12, 13];
619
620        let mut calls = Vec::new();
621        let sampling = SamplingConfig::new(12, 42);
622        let first = {
623            let mut cb = |done: usize, total: usize| calls.push((done, total));
624            lm.generate_codes(&prompt, Some(&prompt), &vocab, &sampling, Some(&mut cb))
625        };
626        let second = lm.generate_codes(&prompt, Some(&prompt), &vocab, &sampling, None);
627
628        assert_eq!(first, second, "same seed must reproduce the same codes");
629        assert!(first.len() <= 12, "cannot exceed the token budget");
630        assert!(first.iter().all(|&c| c < 8), "codes stay in the toy vocab");
631        assert!(!calls.is_empty(), "progress callback should fire");
632        assert!(calls.iter().all(|&(_, total)| total == 12));
633        assert_eq!(calls.last(), Some(&(12, 12)), "budget-bound run completes");
634
635        // A different seed almost surely diverges in which steps emit codes.
636        let third = lm.generate_codes(&prompt, None, &vocab, &SamplingConfig::new(12, 7), None);
637        assert!(third.len() <= 12);
638    }
639}