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, ¶ms)
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}