Skip to main content

forge_dictate/
normalize.rs

1//! Repairs raw speech-recognition output into written text.
2//!
3//! Punctuation, capitalization, filler removal, spoken numbers and dates
4//! rendered in written form, and self-corrections resolved to whatever the
5//! speaker landed on. Text in, text out: this stage never sees audio.
6//!
7//! The model is "S1-mini" by "Superwhisper", 596M parameters, Apache 2.0
8//! plus a clause requiring it to keep that name and capitalization wherever
9//! it is used. llama.cpp reports 751632384 because the tied embedding is
10//! stored materialized and counted twice.
11//!
12//! # The model card describes intent, not measured behaviour
13//!
14//! Every behavioural claim in it that has been tested here has failed to
15//! reproduce: omitting the empty think block returns a `<think>` fragment
16//! rather than nothing, `formal` is not `semi-formal` plus expanded
17//! contractions, and the card's own three-item `lists` example comes back
18//! as prose. The input FORMAT it documents is exact and worth following to
19//! the byte; its statements about what the model will do are not. Run the
20//! claim before building on it.
21
22mod lookup;
23mod prompt;
24
25pub use prompt::{Context, Structure, Styling};
26
27/// The architectures this stage's generator can run: llama.cpp's causal text
28/// models.
29///
30/// **A guard, not a preference.** An encoder or encoder-decoder gguf loads
31/// and then aborts the process mid-decode (`ggml_abort` on a cross-attention
32/// input a causal decode never sets), and an abort cannot be caught, so the
33/// check is by name and it happens before the load. The cleanup feed reads
34/// this same list to decide what it offers, so a candidate and the file the
35/// load accepts cannot disagree.
36pub const CAUSAL_ARCHS: [&str; 9] =
37    ["qwen2", "qwen3", "llama", "gemma", "gemma2", "gemma3", "mistral", "phi2", "phi3"];
38
39use std::io::{Read as _, Seek as _};
40use std::num::NonZeroU32;
41use std::path::{Path, PathBuf};
42use std::sync::OnceLock;
43
44use llama_cpp_2::context::params::LlamaContextParams;
45use llama_cpp_2::llama_backend::LlamaBackend;
46use llama_cpp_2::llama_batch::LlamaBatch;
47use llama_cpp_2::model::LlamaModel;
48use llama_cpp_2::model::params::LlamaModelParams;
49use llama_cpp_2::{LogOptions, send_logs_to_tracing};
50
51/// Everything the normalizer can fail with.
52#[derive(Debug, thiserror::Error)]
53pub enum NormalizeError {
54    /// llama.cpp's global backend refused to start. It is a process
55    /// singleton, so the usual cause is another library in the same
56    /// process having initialized it first.
57    #[error("llama backend could not start: {0}")]
58    Backend(String),
59
60    /// The weights could not be opened. [`crate::prepare`] checks size and
61    /// hash before anything reaches here, so a failure at this point is
62    /// usually a path that names no file.
63    #[error("could not load {}: {source}", path.display())]
64    Load {
65        path: PathBuf,
66        #[source]
67        source: llama_cpp_2::LlamaModelLoadError,
68    },
69
70    /// The file is not a model this stage runs, refused BEFORE the load.
71    ///
72    /// **The check has to happen here, because the alternative is a process
73    /// abort.** llama.cpp loads an encoder-decoder gguf without complaint and
74    /// then asserts inside the first decode on the cross-attention input a
75    /// causal decode never sets - `llm_graph_input_attn_cross::set_input`,
76    /// measured on a t5 build - and a ggml assert aborts rather than
77    /// returning, so nothing can catch it after the load. The architecture is
78    /// read out of the file's own header first, and a file that declares none
79    /// is refused on the same grounds: an undeclared arch is a promise nobody
80    /// can make.
81    #[error(
82        "{} is {arch}, and the normalizer runs {} only",
83        path.display(),
84        CAUSAL_ARCHS.join(", ")
85    )]
86    NotCausal { path: PathBuf, arch: String },
87
88    /// A context could not be created for the loaded weights.
89    #[error("could not create an inference context: {0}")]
90    Context(#[from] llama_cpp_2::LlamaContextLoadError),
91
92    /// The batch would not hold the tokens offered to it.
93    #[error("could not fill the decode batch: {0}")]
94    Batch(#[from] llama_cpp_2::llama_batch::BatchAddError),
95
96    /// llama.cpp rejected a decode step.
97    #[error("decode failed: {0}")]
98    Decode(#[from] llama_cpp_2::DecodeError),
99
100    /// Rejected draft tokens could not be dropped from the KV cache.
101    /// Continuing would attend over tokens that were never emitted.
102    #[error("could not roll back the kv cache: {0}")]
103    KvCache(#[from] llama_cpp_2::context::kv_cache::KvCacheConversionError),
104}
105
106/// llama.cpp's backend is global to the process and may be initialized
107/// exactly once.
108///
109/// Log routing must precede the backend starting, or ggml's Metal
110/// device-init block has already reached stderr. Ordering here is documented
111/// rather than enforced: swapping the two lines below still compiles.
112///
113/// `LlamaBackend::void_logs` is not an alternative. It binds llama's sink
114/// only, leaving ggml's untouched.
115fn backend() -> Result<&'static LlamaBackend, NormalizeError> {
116    static BACKEND: OnceLock<Result<LlamaBackend, String>> = OnceLock::new();
117
118    BACKEND
119        .get_or_init(|| {
120            send_logs_to_tracing(LogOptions::default());
121            LlamaBackend::init().map_err(|e| e.to_string())
122        })
123        .as_ref()
124        .map_err(|e| NormalizeError::Backend(e.clone()))
125}
126
127/// Offload everything; llama.cpp clamps this to the layers that exist and
128/// silently does nothing without an accelerated backend compiled in.
129const GPU_LAYERS: u32 = 999;
130
131/// Per-call settings. [`Default`] is what ships, so a caller that does not
132/// care constructs it without naming an axis.
133#[derive(Clone, Copy, Debug, PartialEq, Eq)]
134pub struct NormalizeOptions {
135    /// The register to rewrite into.
136    pub styling: prompt::Styling,
137    /// Whether the model may return a bulleted list.
138    pub structure: prompt::Structure,
139    /// Destination conventions. `Email` returns multi-line text.
140    pub context: prompt::Context,
141    /// Draft length for speculative decoding. Decoder tuning rather than a
142    /// user-facing choice, and `0` turns speculation off. The optimum is a
143    /// property of the text: speculation drafts from the input, so it pays
144    /// most when the output barely changes.
145    pub k: usize,
146    /// Match width for finding a draft. `0` turns speculation off.
147    pub ngram: usize,
148}
149
150impl Default for NormalizeOptions {
151    fn default() -> Self {
152        Self {
153            styling: prompt::Styling::default(),
154            structure: prompt::Structure::default(),
155            context: prompt::Context::default(),
156            k: lookup::K,
157            ngram: lookup::NGRAM,
158        }
159    }
160}
161
162/// A loaded normalizer. Holds the weights; cheap to call repeatedly.
163pub struct Normalizer {
164    model: LlamaModel,
165}
166
167impl std::fmt::Debug for Normalizer {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        f.debug_struct("Normalizer").field("params", &self.model.n_params()).finish()
170    }
171}
172
173impl Normalizer {
174    /// Load weights from a GGUF file.
175    ///
176    /// The architecture is checked against [`CAUSAL_ARCHS`] BEFORE the load -
177    /// see [`NormalizeError::NotCausal`] for why that order is the whole
178    /// point of the check.
179    pub fn load(path: &Path) -> Result<Self, NormalizeError> {
180        let arch = architecture(path);
181        if !arch.as_deref().is_some_and(|arch| CAUSAL_ARCHS.contains(&arch)) {
182            return Err(NormalizeError::NotCausal {
183                path: path.to_path_buf(),
184                arch: match arch {
185                    Some(arch) => format!("a {arch} model"),
186                    None => "an architecture this file does not declare".to_owned(),
187                },
188            });
189        }
190        let backend = backend()?;
191        let params = LlamaModelParams::default().with_n_gpu_layers(GPU_LAYERS);
192        let model = LlamaModel::load_from_file(backend, path, &params)
193            .map_err(|source| NormalizeError::Load { path: path.to_path_buf(), source })?;
194        tracing::debug!(
195            event_name = "normalizer_loaded",
196            path = %path.display(),
197            params = model.n_params(),
198            "loaded normalizer"
199        );
200        Ok(Self { model })
201    }
202
203    /// Rewrite one raw transcript as written text, at the shipped defaults.
204    ///
205    /// An empty result is a valid answer, not a failure: input that is
206    /// nothing but filler normalizes to nothing.
207    pub fn normalize(&self, text: &str) -> Result<String, NormalizeError> {
208        self.normalize_with(text, NormalizeOptions::default())
209    }
210
211    /// As [`Normalizer::normalize`], with the register and decoder settings
212    /// chosen per call. Nothing here is cached against them, so they cost a
213    /// string format and can change on every call.
214    pub fn normalize_with(
215        &self,
216        text: &str,
217        opts: NormalizeOptions,
218    ) -> Result<String, NormalizeError> {
219        self.run(&prompt::for_options(text, opts), text, opts)
220    }
221
222    /// `source` is what speculation drafts from, and is the transcript
223    /// rather than the prompt wrapped around it.
224    fn run(
225        &self,
226        prompt: &str,
227        source: &str,
228        opts: NormalizeOptions,
229    ) -> Result<String, NormalizeError> {
230        let mut session = self.session(prompt, opts.k)?;
231        // Tokenized alone rather than sliced out of the prompt: generation
232        // starts fresh, so the output's token boundaries match a standalone
233        // tokenization and not an embedded one.
234        let source = self.model.vocab().tokenize(source.as_bytes(), false, true);
235        lookup::generate(&self.model, &mut session, &source, opts.ngram, opts.k)
236    }
237
238    /// Decode the prompt and hand back everything generation needs.
239    fn session(&self, prompt: &str, k: usize) -> Result<Session<'_>, NormalizeError> {
240        let tokens = self.model.vocab().tokenize(prompt.as_bytes(), false, true);
241        let Plan { budget, n_ctx, batch_capacity } = plan(tokens.len(), k);
242
243        let mut ctx = self.model.new_context(
244            backend()?,
245            LlamaContextParams::default().with_n_ctx(NonZeroU32::new(n_ctx)).with_n_batch(n_ctx),
246        )?;
247
248        let mut batch = LlamaBatch::new(batch_capacity, 1);
249        let last = tokens.len().saturating_sub(1);
250        for (i, token) in tokens.iter().enumerate() {
251            batch.add(*token, i32::try_from(i).unwrap_or(i32::MAX), &[0], i == last)?;
252        }
253        ctx.decode(&mut batch)?;
254
255        let start = i32::try_from(tokens.len()).unwrap_or(i32::MAX);
256        Ok(Session { ctx, batch, start, budget })
257    }
258}
259
260/// Decode one token's bytes through a decoder the whole generation reuses.
261/// The decoder must persist across tokens: a piece can end mid-character,
262/// and a fresh decoder would replace both halves.
263fn decode_piece(decoder: &mut encoding_rs::Decoder, bytes: &[u8]) -> String {
264    let mut piece = String::with_capacity(
265        decoder.max_utf8_buffer_length(bytes.len()).unwrap_or(bytes.len().saturating_mul(3)),
266    );
267    let mut rest = bytes;
268    loop {
269        let (result, read, _) = decoder.decode_to_string(rest, &mut piece, false);
270        if matches!(result, encoding_rs::CoderResult::InputEmpty) {
271            return piece;
272        }
273        piece.reserve(rest.len().saturating_mul(3).max(8));
274        rest = &rest[read..];
275    }
276}
277
278/// How much room one call needs.
279struct Plan {
280    /// Generation stops here whatever the model does.
281    budget: usize,
282    /// Positions the context must hold.
283    n_ctx: u32,
284    /// Tokens one batch must hold.
285    batch_capacity: usize,
286}
287
288/// The card's ceiling is 1.3x the input plus 32, taken over the whole prompt
289/// rather than the transcript, so it sits looser than the figure it comes
290/// from.
291///
292/// `n_ctx` covers the highest position ever written: the prompt, then at most
293/// `budget` emitted tokens, then a whole `k`-token draft written speculatively
294/// past the last accepted one. That highest index is
295/// `n_prompt + budget + k - 1`, so the count needed is one more than that and
296/// this leaves exactly one slot spare.
297///
298/// A batch holds either the whole prompt on the first decode or one confirmed
299/// token plus a full draft on every later one, so it takes the larger.
300fn plan(n_prompt: usize, k: usize) -> Plan {
301    let budget = (n_prompt * 13) / 10 + 32;
302    Plan {
303        budget,
304        n_ctx: u32::try_from(n_prompt + budget + k + 1).unwrap_or(u32::MAX),
305        batch_capacity: n_prompt.max(k + 1),
306    }
307}
308
309/// A decoded prompt, ready to generate from.
310struct Session<'a> {
311    ctx: llama_cpp_2::context::LlamaContext<'a>,
312    batch: LlamaBatch<'a>,
313    start: i32,
314    budget: usize,
315}
316
317/// The architecture a gguf declares, read from the file's own header.
318///
319/// **Read BEFORE the load, and that order is the point.** llama.cpp loads an
320/// encoder-decoder gguf without complaint and asserts inside the first
321/// decode, and a ggml assert aborts the process - so the file has to be
322/// refused while refusing is still possible. `None` when the header is not a
323/// GGUF one, is malformed, or declares nothing, which the caller refuses on
324/// the same grounds as an architecture it cannot run.
325fn architecture(path: &Path) -> Option<String> {
326    let mut file = std::fs::File::open(path).ok()?;
327    let mut head = [0_u8; 24];
328    file.read_exact(&mut head).ok()?;
329    if &head[0..4] != b"GGUF" {
330        return None;
331    }
332    let pairs = u64::from_le_bytes(head[16..24].try_into().ok()?);
333    // A header's own count is a claim like any other: the loop ends at the
334    // count, at the first malformed read, and at the file's end.
335    for _ in 0..pairs.min(4096) {
336        let key = read_string(&mut file)?;
337        let kind = read_u32(&mut file)?;
338        if key == "general.architecture" {
339            return (kind == GGUF_STRING).then(|| read_string(&mut file)).flatten();
340        }
341        skip_value(&mut file, kind, 0)?;
342    }
343    None
344}
345
346/// The format's own type tag for a string value.
347const GGUF_STRING: u32 = 8;
348
349fn read_u32(file: &mut std::fs::File) -> Option<u32> {
350    let mut bytes = [0_u8; 4];
351    file.read_exact(&mut bytes).ok()?;
352    Some(u32::from_le_bytes(bytes))
353}
354
355fn read_u64(file: &mut std::fs::File) -> Option<u64> {
356    let mut bytes = [0_u8; 8];
357    file.read_exact(&mut bytes).ok()?;
358    Some(u64::from_le_bytes(bytes))
359}
360
361/// One length-prefixed utf-8 value, bounded so a corrupt length cannot ask
362/// for an allocation nobody has.
363fn read_string(file: &mut std::fs::File) -> Option<String> {
364    let length = usize::try_from(read_u64(file)?).ok()?;
365    if length > 1 << 20 {
366        return None;
367    }
368    let mut bytes = vec![0_u8; length];
369    file.read_exact(&mut bytes).ok()?;
370    String::from_utf8(bytes).ok()
371}
372
373/// Step over one metadata value, by the format's own type table. Anything
374/// the table does not name ends the walk, which the caller answers with the
375/// same refusal as a malformed header.
376fn skip_value(file: &mut std::fs::File, kind: u32, depth: u32) -> Option<()> {
377    if depth > 4 {
378        return None;
379    }
380    let width = match kind {
381        0 | 1 | 7 => 1_u64,
382        2..=3 => 2,
383        4..=6 => 4,
384        10..=12 => 8,
385        GGUF_STRING => {
386            read_string(file)?;
387            return Some(());
388        }
389        9 => {
390            let element = read_u32(file)?;
391            let count = read_u64(file)?;
392            if count > 1 << 24 {
393                return None;
394            }
395            let element_width = match element {
396                0 | 1 | 7 => Some(1_u64),
397                2..=3 => Some(2),
398                4..=6 => Some(4),
399                10..=12 => Some(8),
400                _ => None,
401            };
402            match element_width {
403                Some(element_width) => {
404                    let bytes = i64::try_from(count.checked_mul(element_width)?).ok()?;
405                    file.seek(std::io::SeekFrom::Current(bytes)).ok()?;
406                }
407                None => {
408                    for _ in 0..count {
409                        skip_value(file, element, depth + 1)?;
410                    }
411                }
412            }
413            return Some(());
414        }
415        _ => return None,
416    };
417    file.seek(std::io::SeekFrom::Current(i64::try_from(width).ok()?)).ok()?;
418    Some(())
419}
420
421#[cfg(test)]
422mod tests_architecture {
423    use super::*;
424
425    /// A gguf header carrying one metadata pair, built by the format's own
426    /// table: magic, version, no tensors, one pair, then the pair.
427    fn header(key: &str, kind: u32, value: &[u8]) -> Vec<u8> {
428        let mut bytes = Vec::new();
429        bytes.extend_from_slice(b"GGUF");
430        bytes.extend_from_slice(&3_u32.to_le_bytes());
431        bytes.extend_from_slice(&0_u64.to_le_bytes());
432        bytes.extend_from_slice(&1_u64.to_le_bytes());
433        bytes.extend_from_slice(&(key.len() as u64).to_le_bytes());
434        bytes.extend_from_slice(key.as_bytes());
435        bytes.extend_from_slice(&kind.to_le_bytes());
436        bytes.extend_from_slice(value);
437        bytes
438    }
439
440    /// A string value as the format writes one.
441    fn string(text: &str) -> Vec<u8> {
442        let mut bytes = Vec::new();
443        bytes.extend_from_slice(&(text.len() as u64).to_le_bytes());
444        bytes.extend_from_slice(text.as_bytes());
445        bytes
446    }
447
448    /// **The architecture is read before anything loads**, because the
449    /// failure it guards against is a process abort inside the decode: a t5
450    /// build loads happily and dies on the first cross-attention input.
451    #[test]
452    fn the_architecture_is_read_from_the_header_and_everything_else_is_refused() {
453        let dir = tempfile::tempdir().unwrap();
454
455        let t5 = dir.path().join("t5.gguf");
456        std::fs::write(&t5, header("general.architecture", GGUF_STRING, &string("t5"))).unwrap();
457        assert_eq!(architecture(&t5).as_deref(), Some("t5"));
458
459        let qwen = dir.path().join("qwen.gguf");
460        std::fs::write(&qwen, header("general.architecture", GGUF_STRING, &string("qwen3")))
461            .unwrap();
462        assert_eq!(architecture(&qwen).as_deref(), Some("qwen3"));
463
464        // A file that is not a gguf, a header truncated mid-pair, and a
465        // pair that is not the architecture: all read as no declaration.
466        let other = dir.path().join("weights");
467        std::fs::write(&other, b"weights").unwrap();
468        assert_eq!(architecture(&other), None);
469
470        let truncated = dir.path().join("truncated.gguf");
471        let full = header("general.architecture", GGUF_STRING, &string("qwen3"));
472        std::fs::write(&truncated, &full[..20]).unwrap();
473        assert_eq!(architecture(&truncated), None);
474
475        let unrelated = dir.path().join("unrelated.gguf");
476        std::fs::write(&unrelated, header("general.name", GGUF_STRING, &string("x"))).unwrap();
477        assert_eq!(architecture(&unrelated), None);
478    }
479
480    /// **The load refuses a non-causal architecture by name, and it is the
481    /// load's own refusal that keeps the process alive.** A t5 file loads
482    /// happily and aborts inside ggml's cross-attention - an abort nothing
483    /// can catch - so the guard is the header read in front of the loader,
484    /// and deleting it would leave this the only test that notices.
485    #[test]
486    fn the_load_refuses_a_non_causal_architecture_by_name() {
487        let dir = tempfile::tempdir().unwrap();
488        let t5 = dir.path().join("grammar-t5.gguf");
489        std::fs::write(&t5, header("general.architecture", GGUF_STRING, &string("t5"))).unwrap();
490
491        let refusal = Normalizer::load(&t5).expect_err("t5 is not a generator llama.cpp runs");
492        assert!(matches!(refusal, NormalizeError::NotCausal { .. }), "got: {refusal:?}");
493        assert!(
494            refusal.to_string().contains("t5"),
495            "the refusal names the architecture it read, got: {refusal}"
496        );
497    }
498}
499
500#[cfg(test)]
501mod tests_plan {
502    use super::*;
503
504    /// One spare slot rather than none. Zero would work until the first
505    /// full-length draft, and the failure would surface as a decode error
506    /// deep in a run rather than at the call that sized it.
507    #[test]
508    fn a_context_holds_the_prompt_a_full_run_and_a_whole_rejected_draft() {
509        for n_prompt in [1usize, 2, 69, 100, 512, 4096] {
510            for k in [0usize, 1, 2, 64, 512] {
511                let p = plan(n_prompt, k);
512                // Highest position written: prompt, then budget emitted
513                // tokens, then a whole draft past the last accepted one.
514                let highest = n_prompt + p.budget + k - 1;
515                assert_eq!(
516                    u64::from(p.n_ctx),
517                    highest as u64 + 2,
518                    "n_prompt {n_prompt} k {k}: context must hold every position \
519                     written plus exactly one spare"
520                );
521            }
522        }
523    }
524
525    /// A batch is filled twice with different shapes: the whole prompt on the
526    /// first decode, then one confirmed token plus a full draft on each later
527    /// one. Sizing for either alone overflows on the other.
528    #[test]
529    fn a_batch_holds_the_prompt_and_the_widest_draft() {
530        for n_prompt in [1usize, 69, 4096] {
531            for k in [0usize, 64, 8192] {
532                let c = plan(n_prompt, k).batch_capacity;
533                let widest_generation_batch = k + 1;
534                assert!(
535                    c >= n_prompt,
536                    "n_prompt {n_prompt} k {k}: batch too small for the prompt decode"
537                );
538                assert!(
539                    c >= widest_generation_batch,
540                    "n_prompt {n_prompt} k {k}: batch too small for a confirmed token \
541                     plus a full draft"
542                );
543            }
544        }
545    }
546
547    /// Output length tracks input length, so the ceiling has to scale with
548    /// it rather than sitting at a constant.
549    #[test]
550    fn the_budget_grows_with_the_prompt() {
551        assert!(
552            plan(1000, 0).budget > plan(100, 0).budget,
553            "a longer prompt must be allowed a longer output"
554        );
555    }
556}
557
558/// Ignored by default because they need the 1.5 GB weights on disk, which
559/// CI has no copy of. Fetch them with [`crate::prepare`], then
560/// `cargo nextest run -p forge-dictate --run-ignored all`.
561///
562/// # What a green CI run does not tell you
563///
564/// Everything below needs the weights, so the KV rollback, the accept loop,
565/// the end-of-turn guard and the sampling index are unenforced in CI, and
566/// byte-identity holds of a local run rather than of the repository.
567/// `tests_plan` is model-free and does hold, covering the sizing arithmetic
568/// only.
569///
570/// The `budget` ceiling is unenforced even locally, because both paths stop
571/// on an end-of-turn token first. It scales on the whole prompt while output
572/// tracks only the transcript, so the scaffold alone holds the floor above
573/// any run.
574#[cfg(test)]
575mod tests_against_the_model {
576    use super::*;
577    use crate::ModelSpec;
578    use llama_cpp_2::sampling::LlamaSampler;
579
580    const TRANSCRIPT: &str = "so um i was looking at the the gg uf loader and like i think it \
581                              needs mmap no wait it doesnt need mmap it just needs the file to \
582                              be like fully written before we read it";
583
584    fn normalizer() -> Normalizer {
585        let path = dirs::cache_dir()
586            .map(|d| d.join("forge-dictate").join(ModelSpec::s1_mini_f16().file))
587            .expect("a cache directory is required to locate the weights");
588        Normalizer::load(&path).expect("weights must load; run prepare() first")
589    }
590
591    /// The oracle: one token per decode, no drafts, nothing to roll back.
592    /// The loop is written out rather than reached for in the production
593    /// module, because a reference sharing code with what it validates
594    /// cannot detect a fault the two have in common.
595    ///
596    /// It does share [`Normalizer::session`], so what the gate covers is
597    /// divergence in the generation loop and nothing else. Everything
598    /// `session` decides is common to both sides and therefore invisible to
599    /// the comparison: `budget`, `n_ctx`, batch capacity, which prompt
600    /// position carries logits, and the tokenizer's `add_special = false`.
601    fn greedy(n: &Normalizer, prompt: &str, k: usize) -> String {
602        let mut s = n.session(prompt, k).expect("prompt decodes");
603        let mut sampler = LlamaSampler::greedy();
604        let vocab = n.model.vocab();
605        let mut decoder = encoding_rs::UTF_8.new_decoder();
606        let mut out = String::new();
607        let mut current = sampler.sample(&s.ctx, s.batch.n_tokens() - 1);
608
609        for pos in (s.start..).take(s.budget) {
610            if vocab.is_eog(current) {
611                break;
612            }
613            sampler.accept(current);
614            out.push_str(&decode_piece(&mut decoder, &vocab.token_to_piece(current, false, None)));
615            s.batch.clear();
616            s.batch.add(current, pos, &[0], true).expect("batch has room for one token");
617            s.ctx.decode(&mut s.batch).expect("decode");
618            current = sampler.sample(&s.ctx, 0);
619        }
620        out
621    }
622
623    /// The correctness proof for the KV rollback. Greedy decoding is
624    /// deterministic, so speculation is only a speed change: a single byte
625    /// of difference means a drafted position outlived its rejection, and
626    /// that is a bug in the rollback rather than a quality regression to
627    /// tune away.
628    ///
629    /// The property is claimed for **every** `(ngram, k)`; the pairs below
630    /// are the sampled witnesses, not the extent of the claim, and `k = 0`
631    /// covers the degenerate no-speculation case. The first pair tracks the
632    /// shipped constants so it cannot drift away from them.
633    ///
634    /// Compared against the oracle rather than against another `k`: two
635    /// speculative runs share the drafting loop, so they would agree on a
636    /// fault they both have.
637    #[test]
638    #[ignore = "needs the S1-mini weights on disk"]
639    fn speculative_output_is_byte_identical_to_greedy() {
640        let n = normalizer();
641        for (ngram, k) in [(lookup::NGRAM, lookup::K), (1, 4), (3, 16), (2, 0)] {
642            let opts = NormalizeOptions { k, ngram, ..Default::default() };
643            let plain = greedy(
644                &n,
645                &prompt::build(TRANSCRIPT, opts.styling, opts.structure, opts.context),
646                k,
647            );
648            let spec = n.normalize_with(TRANSCRIPT, opts).expect("speculative generation");
649            assert_eq!(
650                spec, plain,
651                "ngram {ngram} k {k} diverged from greedy; suspect the kv rollback \
652                 leaving a rejected draft behind, not the model"
653            );
654            assert!(
655                !plain.is_empty(),
656                "the oracle produced nothing at ngram {ngram} k {k}, so the comparison \
657                 proves nothing"
658            );
659        }
660    }
661
662    /// The trap, as behaviour rather than as a string. Note what it is NOT:
663    /// the model does not go silent, it answers with a think fragment. An
664    /// assertion that the output is empty would both miss this and fire on
665    /// the legitimate case below.
666    #[test]
667    #[ignore = "needs the S1-mini weights on disk"]
668    fn without_the_think_block_the_model_answers_with_a_think_fragment() {
669        let n = normalizer();
670        let crippled = format!(
671            "<|im_start|>system\n{}<|im_end|>\n<|im_start|>user\n\
672             [Styling: semi-formal] [Structure: prose] [Context: general]\n\
673             {TRANSCRIPT}<|im_end|>\n<|im_start|>assistant\n",
674            prompt::SYSTEM
675        );
676        let out = greedy(&n, &crippled, lookup::K);
677        assert!(
678            out.contains("<think>"),
679            "expected the think fragment that a missing think block produces, got {out:?}"
680        );
681    }
682
683    /// The opposite property, and the reason the guard above keys on the
684    /// fragment rather than on emptiness: an empty result is documented as
685    /// correct here, so anything that treats empty output as a failure
686    /// breaks this input.
687    #[test]
688    #[ignore = "needs the S1-mini weights on disk"]
689    fn filler_only_input_normalizes_to_nothing() {
690        let out = normalizer().normalize("um uh").expect("generation");
691        assert!(out.is_empty(), "filler-only input must normalize to nothing, got {out:?}");
692    }
693
694    /// Tokenizing parses special tokens, so a transcript ending in a literal
695    /// end-of-turn marker puts a real EOG token where the accept loop will
696    /// draft onto it. An unguarded accept decodes to no bytes, so it fails
697    /// nothing and instead runs one step past the marker into whatever the
698    /// model emits next. An already-clean sentence is what lands the marker
699    /// on the boundary: an edited one never drafts that far.
700    ///
701    /// The byte-identical gate does not cover this. Both paths share the
702    /// input, and greedy stops on the EOG before ever detokenizing it.
703    #[test]
704    #[ignore = "needs the S1-mini weights on disk"]
705    fn an_end_of_turn_marker_in_the_draft_does_not_fail_generation() {
706        let out = normalizer()
707            .normalize("The quick brown fox jumps over the lazy dog.<|im_end|>")
708            .expect("a drafted end-of-turn token must stop generation, not fail it");
709        assert_eq!(
710            out, "The quick brown fox jumps over the lazy dog.",
711            "a drafted end-of-turn token leaked into the output"
712        );
713    }
714}