frink_models/speculative.rs
1//! Speculative decoding: propose several candidate next tokens with
2//! something cheap, then verify them all in a single
3//! `Decoder::forward_batch` call instead of one `forward_token` call
4//! per token.
5//!
6//! Two halves, deliberately separated:
7//!
8//! * **Drafting** is the [`Drafter`] trait. The only implementation in
9//! the tree is [`PromptLookupSpeculator`], an n-gram match over the
10//! history with no model at all (prompt-lookup drafting), chosen
11//! because it needs no GPU, no second set
12//! of weights and no checkpoint to be useful. A model-based drafter
13//! (MTP head, EAGLE, dFlash) is a second impl of the same trait.
14//! * **Verification** is [`speculative_decode_with`], and it does not
15//! know or care which drafter proposed the block.
16//!
17//! # Losslessness is the property that matters most here
18//!
19//! Speculative decoding is only worth having if it produces exactly the
20//! same *distribution* the target model would have produced on its own,
21//! just faster. This module implements the speculative-sampling
22//! rejection rule (Leviathan et al. 2023 / Chen et al. 2023): a draft
23//! token `x` proposed with draft probability `q(x)` is accepted with
24//! probability `min(1, p(x)/q(x))`, and on rejection the position is
25//! resampled from the normalised residual `max(0, p - q)`. That rule is
26//! lossless at *every* temperature.
27//!
28//! It is worth being precise about what the previous accept test --
29//! `argmax(target_logits[i]) == guess` -- actually guaranteed, because
30//! it looks like the same thing and is not. Argmax matching is exactly
31//! the special case of the rule above at `temperature = 0`, where `p`
32//! is a point mass: `p(x)` is 1 when the guess is the argmax and 0
33//! otherwise, so acceptance is certain or impossible and the residual
34//! collapses back onto the argmax. Above temperature 0 it is a
35//! different algorithm with a different output distribution -- it
36//! silently biases generation toward the target's argmax, because a
37//! draft token only survives if it happens to be the most likely one.
38//! [`accept_or_resample`] is therefore not an optimisation; it is the
39//! difference between "lossless" being true and being a claim.
40//!
41//! The invariant is tested directly, not assumed:
42//! `resampling_reproduces_the_target_distribution` pushes two hundred
43//! thousand tokens through the accept/reject rule with deliberately bad
44//! draft distributions and asserts the empirical output matches the
45//! target distribution;
46//! `speculative_decode_at_temperature_matches_plain_sampling` compares
47//! a real decode at temperature 1.0 against the target's own exactly
48//! enumerated per-position marginals; and
49//! `speculative_decode_matches_greedy_token_for_token` asserts
50//! token-for-token identity with a plain `forward_token` loop at
51//! temperature 0.
52
53use crate::decoder::Decoder;
54use crate::penalty_window::PenaltyWindow;
55use crate::sampling::{sampling_distribution, Sampler, SamplingParams};
56use frink_core::cache::KvCache;
57
58/// The distribution one drafted position was sampled from, as its
59/// complete support: `(token id, probability)` pairs summing to 1.
60///
61/// Sparse rather than a dense vocabulary-length vector because the
62/// distributions drafters actually produce are sparse: a prompt-lookup
63/// drafter's support is a single token, and a real drafter's is a
64/// top-k, not 150k floats per drafted position per step.
65///
66/// # Contract
67///
68/// The support must be the distribution the draft token was *actually*
69/// sampled from. Losslessness does not require the drafter to be good,
70/// or even sane -- the rejection rule corrects any `q` -- but it does
71/// require `q` to be honest. A drafter that truncates its own softmax
72/// to a top-k before sampling must report the truncated, renormalised
73/// distribution, not the full softmax it started from.
74#[derive(Debug, Clone, PartialEq, Default)]
75pub struct DraftDist {
76 support: Vec<(usize, f32)>,
77}
78
79impl DraftDist {
80 /// A drafter that is certain: all probability on one token. This is
81 /// what a lookup-table drafter honestly reports -- it did not
82 /// sample, it asserted.
83 pub fn deterministic(token: usize) -> Self {
84 DraftDist {
85 support: vec![(token, 1.0)],
86 }
87 }
88
89 /// The nonzero entries of a dense probability vector.
90 pub fn from_dense(probs: &[f32]) -> Self {
91 DraftDist {
92 support: probs
93 .iter()
94 .enumerate()
95 .filter(|&(_, &p)| p > 0.0)
96 .map(|(i, &p)| (i, p))
97 .collect(),
98 }
99 }
100
101 /// Builds from explicit `(token, probability)` pairs.
102 pub fn from_support(support: Vec<(usize, f32)>) -> Self {
103 DraftDist { support }
104 }
105
106 pub fn support(&self) -> &[(usize, f32)] {
107 &self.support
108 }
109
110 /// `q(token)`, or 0.0 for a token outside the support.
111 pub fn prob(&self, token: usize) -> f32 {
112 self.support
113 .iter()
114 .find(|&&(t, _)| t == token)
115 .map(|&(_, p)| p)
116 .unwrap_or(0.0)
117 }
118}
119
120/// A block of drafted tokens plus, per position, the distribution that
121/// position was drawn from.
122///
123/// `tokens` and `dists` are the same length by construction: the
124/// verification rule needs `q` for every token it might have to reject,
125/// so a block that carried tokens without distributions could not be
126/// verified losslessly at all.
127#[derive(Debug, Clone, Default, PartialEq)]
128pub struct DraftBlock {
129 tokens: Vec<usize>,
130 dists: Vec<DraftDist>,
131}
132
133impl DraftBlock {
134 pub fn empty() -> Self {
135 DraftBlock::default()
136 }
137
138 /// Panics if the two vectors disagree in length -- an unverifiable
139 /// block is a programming error in the drafter, not a runtime
140 /// condition to degrade around.
141 pub fn new(tokens: Vec<usize>, dists: Vec<DraftDist>) -> Self {
142 assert_eq!(
143 tokens.len(),
144 dists.len(),
145 "a draft block needs one draft distribution per drafted token"
146 );
147 DraftBlock { tokens, dists }
148 }
149
150 /// A block from a drafter that has no distribution to offer: every
151 /// position is reported as certain. Correct (and lossless) for a
152 /// lookup drafter; wrong for a model-based one, which must report
153 /// its real softmax.
154 pub fn deterministic(tokens: Vec<usize>) -> Self {
155 let dists = tokens
156 .iter()
157 .map(|&t| DraftDist::deterministic(t))
158 .collect();
159 DraftBlock { tokens, dists }
160 }
161
162 pub fn tokens(&self) -> &[usize] {
163 &self.tokens
164 }
165
166 pub fn dists(&self) -> &[DraftDist] {
167 &self.dists
168 }
169
170 pub fn len(&self) -> usize {
171 self.tokens.len()
172 }
173
174 pub fn is_empty(&self) -> bool {
175 self.tokens.is_empty()
176 }
177
178 /// Drops everything past `len` positions, keeping tokens and
179 /// distributions in step.
180 pub fn truncate(&mut self, len: usize) {
181 self.tokens.truncate(len);
182 self.dists.truncate(len);
183 }
184}
185
186/// Proposes a block of candidate continuation tokens.
187///
188/// The signature carries two things a plain `fn(&[usize]) -> Vec<usize>`
189/// cannot express, and both are load-bearing:
190///
191/// * **Per-position draft probabilities**, without which
192/// [`accept_or_resample`] cannot run and speculation is only lossless
193/// at temperature 0.
194/// * **The target model's hidden state** for the last position whose KV
195/// is committed. Every model-based drafter worth having (EAGLE, MTP,
196/// dFlash) conditions on it, and `Decoder::forward_batch_with_hidden`
197/// already computes it as a by-product of verification, so a drafter
198/// that wanted it would otherwise have to run the target twice.
199/// Drafters that do not need it, like [`PromptLookupSpeculator`],
200/// ignore the argument.
201pub trait Drafter {
202 /// Proposes at most `max_len` tokens to follow `history`.
203 /// `target_hidden` is the target model's final-layer hidden state
204 /// for `history`'s last token, or empty when none is available yet
205 /// (which a drafter that needs it must handle by proposing
206 /// nothing).
207 /// Takes `&mut self` because a drafter that is itself a model
208 /// carries KV state across calls, and hiding that behind interior
209 /// mutability would put a runtime borrow check on the hot path to
210 /// buy nothing. A stateless drafter simply ignores it.
211 fn propose(&mut self, history: &[usize], target_hidden: &[f32], max_len: usize) -> DraftBlock;
212}
213
214/// Proposes candidate continuation tokens by looking for the longest
215/// available match of the most recent `ngram_size` tokens earlier in
216/// `history`, and returning up to `max_draft_len` tokens that followed
217/// that earlier occurrence. Returns an empty block if no match is found
218/// or `history` is too short to contain one.
219///
220/// This is deliberately simple (last-match-wins, not best-match or a
221/// frequency-weighted choice): the whole point of prompt-lookup
222/// decoding is that it's nearly free to compute, since a wrong guess
223/// costs nothing but a rejected batch position, not a correctness bug.
224#[derive(Debug, Clone, Copy)]
225pub struct PromptLookupSpeculator {
226 pub ngram_size: usize,
227 pub max_draft_len: usize,
228}
229
230impl PromptLookupSpeculator {
231 pub fn new(ngram_size: usize, max_draft_len: usize) -> Self {
232 assert!(ngram_size >= 1, "ngram_size must be at least 1");
233 assert!(max_draft_len >= 1, "max_draft_len must be at least 1");
234 PromptLookupSpeculator {
235 ngram_size,
236 max_draft_len,
237 }
238 }
239
240 /// Looks for the most recent earlier occurrence of `history`'s
241 /// last `ngram_size` tokens, scanning from the end backwards so
242 /// the *most recent* match wins (most likely to reflect current
243 /// context, e.g. a loop the model is currently in). Returns the
244 /// tokens that followed that occurrence, truncated to
245 /// `max_draft_len`.
246 pub fn propose_tokens(&self, history: &[usize]) -> Vec<usize> {
247 if history.len() < self.ngram_size + 1 {
248 return Vec::new();
249 }
250 let needle = &history[history.len() - self.ngram_size..];
251
252 // Search every earlier start position, latest first. The last
253 // possible start that still leaves room for the needle without
254 // overlapping into the needle itself is history.len() -
255 // ngram_size - 1 (exclusive of the needle's own occurrence).
256 let last_possible_start = history.len() - self.ngram_size - 1;
257 for start in (0..=last_possible_start).rev() {
258 if &history[start..start + self.ngram_size] == needle {
259 let continuation_start = start + self.ngram_size;
260 let available = history.len() - continuation_start;
261 let take = available.min(self.max_draft_len);
262 return history[continuation_start..continuation_start + take].to_vec();
263 }
264 }
265 Vec::new()
266 }
267}
268
269impl Drafter for PromptLookupSpeculator {
270 fn propose(&mut self, history: &[usize], _target_hidden: &[f32], max_len: usize) -> DraftBlock {
271 let mut tokens = self.propose_tokens(history);
272 tokens.truncate(max_len.min(self.max_draft_len));
273 DraftBlock::deterministic(tokens)
274 }
275}
276
277/// The speculative-sampling accept/reject decision for one drafted
278/// position.
279///
280/// `target` is the target model's *final* sampling distribution for
281/// this position -- what [`sampling_distribution`] returns, i.e. after
282/// penalties, temperature, top-k and top-p, because that is the
283/// distribution the non-speculative path would have drawn from.
284/// `draft` is the distribution `token` was drawn from.
285///
286/// Returns `None` when the draft token is accepted, and
287/// `Some(replacement)` when it is rejected -- the replacement is drawn
288/// from the normalised residual `max(0, target - draft)`, which is what
289/// makes the combined procedure's output distribution equal to
290/// `target` exactly rather than approximately.
291///
292/// A token with `draft(token) == 0.0` violates the [`DraftDist`]
293/// contract (it could not have been sampled from `draft`); it is
294/// accepted, matching the `p/q -> infinity` limit, rather than
295/// silently biasing the result.
296pub fn accept_or_resample(
297 target: &[f32],
298 draft: &DraftDist,
299 token: usize,
300 rng: &mut Sampler,
301) -> Option<usize> {
302 let p = target.get(token).copied().unwrap_or(0.0);
303 let q = draft.prob(token);
304 if q <= 0.0 || p >= q {
305 return None;
306 }
307 // p < q, so the accept probability p/q is a real coin flip.
308 if rng.uniform() < p / q {
309 return None;
310 }
311
312 // Rejected: draw the replacement from the normalised residual.
313 let mut residual = target.to_vec();
314 for &(t, qt) in draft.support() {
315 if let Some(r) = residual.get_mut(t) {
316 *r = (*r - qt).max(0.0);
317 }
318 }
319 let total: f32 = residual.iter().sum();
320 if total <= 0.0 {
321 // Only reachable when target and draft are the same
322 // distribution, in which case acceptance was certain and we
323 // cannot be here -- but sampling from nothing is not an option,
324 // so fall back to the target itself.
325 return Some(rng.sample_from(target));
326 }
327 for r in residual.iter_mut() {
328 *r /= total;
329 }
330 Some(rng.sample_from(&residual))
331}
332
333/// Everything `speculative_decode_with` needs beyond the model, the
334/// prompt and the drafter.
335#[derive(Debug, Clone, Default)]
336pub struct SpeculativeOptions {
337 pub max_new_tokens: usize,
338 /// Absolute position of the first `prompt_tokens` token in the KV
339 /// cache: 0 for a fresh cache, `cache.seq_len` when resuming a
340 /// warm one (a prefix-cache hit, or a second call continuing the
341 /// first). Every position and every rollback length inside the
342 /// decode loop is absolute, so a non-zero base is not a special
343 /// case -- see `rolls_back_to_absolute_positions_on_a_warm_cache`.
344 pub start_pos: usize,
345 /// The sampling configuration the *target* model would have used
346 /// without speculation. Verification is lossless with respect to
347 /// exactly these parameters (see [`accept_or_resample`]).
348 pub sampling: SamplingParams,
349 pub seed: u64,
350}
351
352/// Result of a speculative decode run, with the counters that make its
353/// actual savings observable rather than just assumed.
354#[derive(Debug, Clone, Default)]
355pub struct SpeculativeDecodeResult {
356 pub generated_tokens: Vec<usize>,
357 /// Number of `Decoder::forward_batch` calls made (prefill counts as
358 /// one call, each subsequent accept/reject round counts as one
359 /// more, regardless of how many tokens that round produced).
360 pub forward_calls: usize,
361 /// Total tokens produced across all rounds -- always equal to
362 /// `generated_tokens.len()`, kept as a separate field so the ratio
363 /// `tokens_generated / forward_calls` (the actual speedup metric)
364 /// is easy to read directly off this struct.
365 pub tokens_generated: usize,
366 /// Verification rounds: `forward_calls` minus the prefill call.
367 /// This is the denominator of the published *acceptance length*
368 /// metric.
369 pub verification_steps: usize,
370 /// Draft tokens the target actually evaluated. Positions past a
371 /// rejection are never evaluated, so they are not counted here --
372 /// counting them would deflate the accept rate by the drafter's
373 /// block size rather than by its accuracy.
374 pub drafted_tokens: usize,
375 /// Draft tokens accepted.
376 pub accepted_tokens: usize,
377 /// Per drafted position (0 = first token after the anchor), how
378 /// many times that position was *evaluated*, i.e. reached without
379 /// an earlier rejection ending the round.
380 pub evaluated_at_position: Vec<usize>,
381 /// Per drafted position, how many times it was accepted.
382 pub accepted_at_position: Vec<usize>,
383}
384
385impl SpeculativeDecodeResult {
386 /// Average tokens produced per `forward_batch` call. 1.0 means
387 /// speculation never helped (every round produced exactly the
388 /// anchor token); higher means draft tokens were accepted.
389 pub fn tokens_per_call(&self) -> f64 {
390 if self.forward_calls == 0 {
391 0.0
392 } else {
393 self.tokens_generated as f64 / self.forward_calls as f64
394 }
395 }
396
397 /// The published metric: completion tokens per verification step.
398 /// `None` when nothing was verified (an empty run), because a zero
399 /// there would read as "speculation made things worse" rather than
400 /// "speculation did not run".
401 ///
402 /// Deliberately not the same number as [`Self::tokens_per_call`],
403 /// which charges the one-off prefill call against the average and
404 /// so understates a short run.
405 pub fn acceptance_length(&self) -> Option<f64> {
406 if self.verification_steps == 0 {
407 None
408 } else {
409 Some(self.tokens_generated as f64 / self.verification_steps as f64)
410 }
411 }
412
413 /// Fraction of drafted positions accepted, over all positions.
414 pub fn accept_rate(&self) -> Option<f64> {
415 if self.drafted_tokens == 0 {
416 None
417 } else {
418 Some(self.accepted_tokens as f64 / self.drafted_tokens as f64)
419 }
420 }
421
422 /// Accept rate at each drafted position, conditional on that
423 /// position having been reached.
424 ///
425 /// A single mean cannot distinguish a drafter that is uniformly
426 /// mediocre from one that is excellent at position 0 and useless by
427 /// position 7, and the two want opposite responses (raise the block
428 /// size, or lower it). The published motivation for dFlash2's
429 /// two-tap convolution is exactly this curve falling from 99.5% to
430 /// 87.8% across a block, so it has to be visible per position or
431 /// the diagnosis is not testable here.
432 pub fn accept_rate_per_position(&self) -> Vec<f64> {
433 self.evaluated_at_position
434 .iter()
435 .zip(self.accepted_at_position.iter())
436 .map(|(&seen, &ok)| {
437 if seen == 0 {
438 0.0
439 } else {
440 ok as f64 / seen as f64
441 }
442 })
443 .collect()
444 }
445
446 fn record_position(&mut self, position: usize, accepted: bool) {
447 if self.evaluated_at_position.len() <= position {
448 self.evaluated_at_position.resize(position + 1, 0);
449 self.accepted_at_position.resize(position + 1, 0);
450 }
451 self.evaluated_at_position[position] += 1;
452 self.drafted_tokens += 1;
453 if accepted {
454 self.accepted_at_position[position] += 1;
455 self.accepted_tokens += 1;
456 }
457 }
458}
459
460/// Greedy speculative decode over a **fresh** KV cache, with
461/// prompt-lookup drafting. Thin wrapper over
462/// [`speculative_decode_with`], kept for callers that want the original
463/// no-options shape.
464pub fn speculative_decode<D: Drafter + ?Sized>(
465 decoder: &Decoder,
466 prompt_tokens: &[usize],
467 max_new_tokens: usize,
468 kv_caches: &mut [KvCache],
469 drafter: &mut D,
470) -> SpeculativeDecodeResult {
471 speculative_decode_observed(
472 decoder,
473 prompt_tokens,
474 kv_caches,
475 drafter,
476 &mut |_| true,
477 &SpeculativeOptions {
478 max_new_tokens,
479 ..SpeculativeOptions::default()
480 },
481 )
482}
483
484/// Decodes `options.max_new_tokens` tokens, using `drafter` to propose
485/// candidate continuations and verifying each block in a single batched
486/// call.
487///
488/// `prompt_tokens` is processed as one prefill batch (one
489/// `forward_batch` call for the whole prompt, not one per prompt token
490/// -- itself a real saving independent of speculation).
491///
492/// # Cache state
493///
494/// `kv_caches` may be warm. `options.start_pos` states where
495/// `prompt_tokens` begins, and must equal every cache's current
496/// `seq_len` -- the caches hold exactly the context preceding the
497/// prompt, and this function appends to them. On return they hold that
498/// context plus the prompt plus every generated token *except* the last
499/// (whose KV is not computed until it is fed, which the next call does
500/// for free by passing it as the anchor).
501///
502/// # Output distribution
503///
504/// Identical to plain token-at-a-time sampling from `decoder` with
505/// `options.sampling`, at any temperature. See the module docs.
506pub fn speculative_decode_with<D: Drafter + ?Sized>(
507 decoder: &Decoder,
508 prompt_tokens: &[usize],
509 kv_caches: &mut [KvCache],
510 drafter: &mut D,
511 options: &SpeculativeOptions,
512) -> SpeculativeDecodeResult {
513 speculative_decode_observed(
514 decoder,
515 prompt_tokens,
516 kv_caches,
517 drafter,
518 &mut |_| true,
519 options,
520 )
521}
522
523/// [`speculative_decode_with`], plus an observer called once per
524/// committed token, in order, which can end the run by returning
525/// `false`.
526///
527/// This exists so a caller that streams output, or that stops on an EOS
528/// or a stop string, does not have to write a second copy of the
529/// verification loop. Copying that loop to vary it is how this project
530/// lost five model features from one duplicated decode path, and the
531/// rejection rule is the last code in the tree that should be
532/// duplicated: a subtly different copy is still lossless-looking.
533///
534/// The observer sees a token only once it is COMMITTED, so it never
535/// sees a draft that was rejected. Returning `false` ends generation
536/// after the current verification block finishes, which keeps the KV
537/// caches in the single consistent state this function documents;
538/// `generated_tokens` is truncated at the token that said stop, so the
539/// caller's output and the returned tokens agree.
540pub fn speculative_decode_observed<D: Drafter + ?Sized>(
541 decoder: &Decoder,
542 prompt_tokens: &[usize],
543 kv_caches: &mut [KvCache],
544 drafter: &mut D,
545 on_token: &mut dyn FnMut(usize) -> bool,
546 options: &SpeculativeOptions,
547) -> SpeculativeDecodeResult {
548 assert!(!prompt_tokens.is_empty(), "prompt must not be empty");
549 // A rejected draft rolls every cache back to the last accepted
550 // position, which a Mamba layer's state cannot do
551 // (`frink_core::recurrent_state`); the CLI refuses `--model-draft`
552 // on such a model before reaching here, and this is the backstop.
553 assert!(
554 !decoder.config.has_recurrent_layers(),
555 "speculative decoding rolls the KV caches back on a rejected draft, and a recurrent \
556 layer's state cannot be rolled back to a middle position"
557 );
558 for cache in kv_caches.iter() {
559 assert_eq!(
560 cache.positions(),
561 options.start_pos,
562 "start_pos must be the caches' current length: they hold exactly the \
563 context preceding the prompt"
564 );
565 }
566
567 let mut result = SpeculativeDecodeResult::default();
568 if options.max_new_tokens == 0 {
569 return result;
570 }
571
572 let mut rng = Sampler::new(options.seed);
573 let mut history: Vec<usize> = prompt_tokens.to_vec();
574 let mut generated: Vec<usize> = Vec::with_capacity(options.max_new_tokens);
575
576 // Prefill: one batched call over the whole prompt.
577 let (prefill_logits, prefill_hidden) =
578 decoder.forward_batch_with_hidden(prompt_tokens, options.start_pos, kv_caches);
579 result.forward_calls += 1;
580 let last = prefill_logits
581 .last()
582 .expect("prompt_tokens is non-empty, so forward_batch returns at least one logits vector");
583 let mut target_hidden = prefill_hidden.last().cloned().unwrap_or_default();
584
585 // `pending` is decided but its KV is not in the cache yet: it is
586 // fed as the anchor of the next batch, which is what lets one
587 // forward call both commit it and verify a block after it.
588 let mut pending = {
589 // Split ONE structure rather than pairing `history` with the
590 // separate `generated` vector: the two would then have to agree
591 // about every push, which is exactly the shape this fix exists
592 // to remove.
593 let (seen_prompt, seen_generated) = history.split_at(prompt_tokens.len());
594 let xtc_roll = rng.xtc_roll(&options.sampling);
595 let probs = sampling_distribution(
596 last,
597 &options.sampling,
598 PenaltyWindow::new(seen_prompt, seen_generated),
599 xtc_roll,
600 );
601 rng.sample_from(&probs)
602 };
603 let mut pos = options.start_pos + prompt_tokens.len();
604
605 // Set when the observer asks to stop. The current block still runs
606 // to completion so the caches end in the one state this function
607 // documents, and `generated` is cut back to this length afterwards.
608 let mut stop_at: Option<usize> = None;
609
610 loop {
611 generated.push(pending);
612 history.push(pending);
613 if !on_token(pending) {
614 stop_at = Some(generated.len());
615 break;
616 }
617 if generated.len() == options.max_new_tokens {
618 break;
619 }
620 // One short of the remaining budget on purpose: the last token
621 // of the run is always committed as an anchor at the top of the
622 // loop, never as an accepted draft. That keeps the cache in
623 // exactly one state on return (see the doc comment) instead of
624 // one state when the budget runs out on an anchor and another
625 // when it runs out mid-block -- and it costs nothing, because
626 // the drafted position it gives up is one whose KV would have
627 // had to be discarded anyway.
628 let draft_budget = options.max_new_tokens - generated.len() - 1;
629
630 // The drafter is asked to continue a history that *includes*
631 // `pending`, because the first drafted token lands at pos + 1.
632 let mut draft = drafter.propose(&history, &target_hidden, draft_budget);
633 draft.truncate(draft_budget);
634
635 let mut batch = Vec::with_capacity(1 + draft.len());
636 batch.push(pending);
637 batch.extend_from_slice(draft.tokens());
638
639 let (batch_logits, batch_hidden) =
640 decoder.forward_batch_with_hidden(&batch, pos, kv_caches);
641 result.forward_calls += 1;
642 result.verification_steps += 1;
643
644 // batch_logits[i] is the target's distribution for the position
645 // right after batch[i], i.e. the distribution draft token i
646 // should be judged against.
647 let mut accepted = 0usize;
648 let mut replacement: Option<usize> = None;
649 for (i, (&token, dist)) in draft.tokens().iter().zip(draft.dists()).enumerate() {
650 let (seen_prompt, seen_generated) = history.split_at(prompt_tokens.len());
651 let xtc_roll = rng.xtc_roll(&options.sampling);
652 let target = sampling_distribution(
653 &batch_logits[i],
654 &options.sampling,
655 PenaltyWindow::new(seen_prompt, seen_generated),
656 xtc_roll,
657 );
658 match accept_or_resample(&target, dist, token, &mut rng) {
659 None => {
660 result.record_position(i, true);
661 accepted += 1;
662 history.push(token);
663 generated.push(token);
664 if stop_at.is_none() && !on_token(token) {
665 // Keep verifying the rest of the block: the
666 // loop below truncates the caches to exactly
667 // what was committed, and leaving early here
668 // would skip that.
669 stop_at = Some(generated.len());
670 }
671 }
672 Some(resampled) => {
673 result.record_position(i, false);
674 replacement = Some(resampled);
675 break;
676 }
677 }
678 }
679
680 // Every position past `accepted` was computed from a token that
681 // is not going to be committed, so its KV is wrong. Lengths are
682 // absolute, which is what makes a warm cache work: `pos` is
683 // already offset by start_pos.
684 let committed_len = pos + 1 + accepted;
685 if accepted < draft.len() {
686 for cache in kv_caches.iter_mut() {
687 cache.truncate(committed_len);
688 }
689 }
690 // POSITIONS: `committed_len` is absolute, offset by start_pos.
691 debug_assert!(kv_caches.iter().all(|c| c.positions() == committed_len));
692
693 target_hidden = batch_hidden[accepted].clone();
694 pending = match replacement {
695 // A rejected position was resampled from the residual; that
696 // token is committed and becomes the next anchor.
697 Some(tok) => tok,
698 // Every draft token was accepted, so the last row of the
699 // batch predicts a genuinely new position -- the free bonus
700 // token that makes a fully-accepted block worth `k + 1`.
701 None => {
702 let (seen_prompt, seen_generated) = history.split_at(prompt_tokens.len());
703 let xtc_roll = rng.xtc_roll(&options.sampling);
704 let probs = sampling_distribution(
705 &batch_logits[accepted],
706 &options.sampling,
707 PenaltyWindow::new(seen_prompt, seen_generated),
708 xtc_roll,
709 );
710 rng.sample_from(&probs)
711 }
712 };
713 pos = committed_len;
714 if stop_at.is_some() {
715 break;
716 }
717 debug_assert!(generated.len() < options.max_new_tokens);
718 }
719
720 if let Some(len) = stop_at {
721 // The observer said stop at this token. Everything after it was
722 // produced by a block that had already been dispatched, and the
723 // caller never saw it.
724 generated.truncate(len);
725 } else {
726 debug_assert_eq!(generated.len(), options.max_new_tokens);
727 }
728 result.tokens_generated = generated.len();
729 result.generated_tokens = generated;
730 result
731}
732
733#[cfg(test)]
734mod tests {
735 use super::*;
736 use crate::config::glm_5_2;
737 use crate::ModelConfig;
738 use std::cell::RefCell;
739
740 fn tiny_test_config() -> ModelConfig {
741 let mut cfg = glm_5_2();
742 cfg.hidden_dim = 16;
743 cfg.n_heads = 4;
744 cfg.n_kv_heads = 2;
745 cfg.head_dim = 4;
746 cfg.moe.hidden_dim = 16;
747 cfg.moe.n_experts = 6;
748 cfg.moe.n_experts_active = 2;
749 cfg.moe.n_shared_experts = 1;
750 cfg.moe.expert_ffn_dim = 8;
751 cfg
752 }
753
754 fn caches(decoder: &Decoder) -> Vec<KvCache> {
755 decoder.config.new_kv_caches()
756 }
757
758 fn argmax(logits: &[f32]) -> usize {
759 logits
760 .iter()
761 .enumerate()
762 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
763 .map(|(i, _)| i)
764 .unwrap_or(0)
765 }
766
767 /// A drafter that always proposes the same block, with a
768 /// configurable draft distribution, and records the history it was
769 /// asked about.
770 struct FixedDrafter {
771 block: DraftBlock,
772 seen_history: RefCell<Vec<Vec<usize>>>,
773 seen_hidden_len: RefCell<Vec<usize>>,
774 }
775
776 impl FixedDrafter {
777 fn new(block: DraftBlock) -> Self {
778 FixedDrafter {
779 block,
780 seen_history: RefCell::new(Vec::new()),
781 seen_hidden_len: RefCell::new(Vec::new()),
782 }
783 }
784 }
785
786 impl Drafter for FixedDrafter {
787 fn propose(
788 &mut self,
789 history: &[usize],
790 target_hidden: &[f32],
791 max_len: usize,
792 ) -> DraftBlock {
793 self.seen_history.borrow_mut().push(history.to_vec());
794 self.seen_hidden_len.borrow_mut().push(target_hidden.len());
795 let mut block = self.block.clone();
796 block.truncate(max_len);
797 block
798 }
799 }
800
801 // ---- PromptLookupSpeculator tests ----
802
803 #[test]
804 fn proposes_the_continuation_after_a_real_repeat() {
805 let mut spec = PromptLookupSpeculator::new(2, 4);
806 // "...1 2 3 4 5 9 9 9 1 2" -> earlier "1 2" occurs at the very
807 // start (indices 0-1); the 4 tokens that followed it are
808 // "3 4 5 9" (capped at max_draft_len=4).
809 let history = vec![1, 2, 3, 4, 5, 9, 9, 9, 1, 2];
810 assert_eq!(spec.propose_tokens(&history), vec![3, 4, 5, 9]);
811 assert_eq!(
812 spec.propose(&history, &[], 8),
813 DraftBlock::deterministic(vec![3, 4, 5, 9])
814 );
815 }
816
817 #[test]
818 fn respects_max_draft_len() {
819 let spec = PromptLookupSpeculator::new(2, 2);
820 let history = vec![1, 2, 3, 4, 5, 6, 7, 1, 2];
821 assert_eq!(spec.propose_tokens(&history), vec![3, 4]);
822 }
823
824 #[test]
825 fn returns_empty_when_no_earlier_match_exists() {
826 let spec = PromptLookupSpeculator::new(2, 4);
827 let history = vec![1, 2, 3, 4, 5];
828 assert_eq!(spec.propose_tokens(&history), Vec::<usize>::new());
829 }
830
831 #[test]
832 fn returns_empty_when_history_too_short() {
833 let spec = PromptLookupSpeculator::new(3, 4);
834 let history = vec![1, 2, 3];
835 assert_eq!(spec.propose_tokens(&history), Vec::<usize>::new());
836 }
837
838 #[test]
839 fn finds_the_most_recent_match_when_several_exist() {
840 let spec = PromptLookupSpeculator::new(1, 3);
841 // needle = [9]. Earlier occurrences at index 0 (-> [8,7,6]) and
842 // index 4 (-> [5,4,9]); most recent (index 4) should win.
843 let history = vec![9, 8, 7, 6, 9, 5, 4, 9];
844 assert_eq!(spec.propose_tokens(&history), vec![5, 4, 9]);
845 }
846
847 #[test]
848 fn the_trait_caps_a_block_at_the_callers_budget() {
849 let mut spec = PromptLookupSpeculator::new(2, 4);
850 let history = vec![1, 2, 3, 4, 5, 9, 9, 9, 1, 2];
851 let block = spec.propose(&history, &[], 2);
852 assert_eq!(block.tokens(), &[3, 4]);
853 assert_eq!(block.dists().len(), 2);
854 }
855
856 // ---- the rejection rule ----
857
858 #[test]
859 fn a_draft_at_least_as_likely_under_the_target_is_always_accepted() {
860 let target = vec![0.6f32, 0.3, 0.1];
861 let draft = DraftDist::from_dense(&[0.5, 0.4, 0.1]);
862 let mut rng = Sampler::new(1);
863 for _ in 0..100 {
864 // p(0)=0.6 >= q(0)=0.5, so token 0 is never rejected.
865 assert_eq!(accept_or_resample(&target, &draft, 0, &mut rng), None);
866 }
867 }
868
869 #[test]
870 fn a_draft_the_target_rules_out_is_always_rejected() {
871 let target = vec![0.5f32, 0.5, 0.0];
872 let draft = DraftDist::deterministic(2);
873 let mut rng = Sampler::new(2);
874 for _ in 0..50 {
875 let replacement = accept_or_resample(&target, &draft, 2, &mut rng);
876 let tok = replacement.expect("p(2) = 0 means token 2 can never be accepted");
877 assert!(tok < 2, "residual must never resample the rejected token");
878 }
879 }
880
881 #[test]
882 fn resampling_reproduces_the_target_distribution() {
883 // THE invariant. Draw a token from the draft distribution, run
884 // it through the accept/reject rule, and the result must be
885 // distributed as the TARGET, no matter how bad the draft is.
886 //
887 // A test that only checked "it runs" would pass on the old
888 // argmax rule, which concentrates mass on the target's argmax
889 // and is not the target distribution at all.
890 let target = vec![0.30f32, 0.25, 0.20, 0.15, 0.07, 0.03];
891 let drafts = [
892 // A drafter that is simply wrong about which token is likely.
893 DraftDist::from_dense(&[0.02, 0.03, 0.05, 0.10, 0.30, 0.50]),
894 // A deterministic drafter, i.e. prompt lookup.
895 DraftDist::deterministic(3),
896 // A drafter whose support misses most of the target's.
897 DraftDist::from_support(vec![(0, 0.5), (5, 0.5)]),
898 // A perfect drafter.
899 DraftDist::from_dense(&target),
900 ];
901 let draws = 200_000;
902 for (d, draft) in drafts.iter().enumerate() {
903 let mut rng = Sampler::new(0xA11CE + d as u64);
904 let mut counts = vec![0usize; target.len()];
905 for _ in 0..draws {
906 // Sample the draft token from the draft distribution --
907 // the rule is only lossless when q is honest about
908 // where the token came from.
909 let dense = {
910 let mut v = vec![0.0f32; target.len()];
911 for &(t, p) in draft.support() {
912 v[t] = p;
913 }
914 v
915 };
916 let x = rng.sample_from(&dense);
917 let out = accept_or_resample(&target, draft, x, &mut rng).unwrap_or(x);
918 counts[out] += 1;
919 }
920 let tv: f64 = counts
921 .iter()
922 .enumerate()
923 .map(|(i, &c)| (c as f64 / draws as f64 - target[i] as f64).abs())
924 .sum::<f64>()
925 / 2.0;
926 assert!(
927 tv < 0.01,
928 "draft {d}: speculative output distribution differs from the target \
929 (total variation {tv:.4}); counts={counts:?}"
930 );
931 }
932 }
933
934 // ---- speculative_decode correctness tests ----
935
936 #[test]
937 fn speculative_decode_matches_greedy_token_for_token() {
938 // Quality-neutrality at temperature 0: token-for-token identity
939 // against a plain sequential forward_token loop on a
940 // separately constructed but identically-seeded decoder.
941 let cfg = tiny_test_config();
942 let vocab = 8;
943 let prompt = vec![1usize, 2, 3, 4, 1, 2];
944 let max_new = 6;
945
946 let decoder_a = Decoder::new_random_small(cfg.clone(), 2, vocab);
947 let mut caches_a = caches(&decoder_a);
948 let mut speculator = PromptLookupSpeculator::new(2, 3);
949 let result =
950 speculative_decode(&decoder_a, &prompt, max_new, &mut caches_a, &mut speculator);
951
952 let decoder_b = Decoder::new_random_small(cfg, 2, vocab);
953 let mut caches_b = caches(&decoder_b);
954 let mut pending = decoder_b
955 .forward_batch(&prompt, 0, &mut caches_b)
956 .pop()
957 .unwrap();
958 let mut greedy = Vec::with_capacity(max_new);
959 for pos in (prompt.len()..).take(max_new) {
960 let tok = argmax(&pending);
961 greedy.push(tok);
962 pending = decoder_b.forward_token(tok, pos, &mut caches_b);
963 }
964
965 assert_eq!(
966 result.generated_tokens, greedy,
967 "speculative decode must produce exactly the same tokens as plain greedy decode"
968 );
969 }
970
971 /// The exact per-position marginal distributions of plain
972 /// token-at-a-time sampling from `decoder`, by enumerating every
973 /// prefix rather than sampling them. Only tractable because the
974 /// test model has a 6-token vocabulary and the horizon is 3, but
975 /// worth it: the speculative sampler is then compared against the
976 /// truth, not against a second noisy estimate of it.
977 fn exact_marginals(
978 decoder: &Decoder,
979 prompt: &[usize],
980 params: &SamplingParams,
981 depth: usize,
982 vocab: usize,
983 ) -> Vec<Vec<f64>> {
984 #[allow(clippy::too_many_arguments)]
985 fn walk(
986 decoder: &Decoder,
987 kv: &[KvCache],
988 logits: &[f32],
989 history: &mut Vec<usize>,
990 weight: f64,
991 level: usize,
992 depth: usize,
993 pos: usize,
994 params: &SamplingParams,
995 marginals: &mut [Vec<f64>],
996 ) {
997 // `history` is already prompt-then-generated, and the
998 // window only ever reads the tail of the two halves
999 // together, so the whole sequence goes in the first one.
1000 // This helper enumerates the exact marginal over every
1001 // continuation, so it must not depend on a random draw:
1002 // `None` is correct here and only here, because the params
1003 // it is called with never enable XTC.
1004 debug_assert!(
1005 !params.xtc_can_fire(),
1006 "the exact marginal is not defined under XTC"
1007 );
1008 let probs =
1009 sampling_distribution(logits, params, PenaltyWindow::new(history, &[]), None);
1010 for (token, &p) in probs.iter().enumerate() {
1011 if p <= 0.0 {
1012 continue;
1013 }
1014 marginals[level][token] += weight * p as f64;
1015 if level + 1 == depth {
1016 continue;
1017 }
1018 let mut branch: Vec<KvCache> = kv.to_vec();
1019 let next = decoder.forward_token(token, pos, &mut branch);
1020 history.push(token);
1021 walk(
1022 decoder,
1023 &branch,
1024 &next,
1025 history,
1026 weight * p as f64,
1027 level + 1,
1028 depth,
1029 pos + 1,
1030 params,
1031 marginals,
1032 );
1033 history.pop();
1034 }
1035 }
1036
1037 let mut marginals = vec![vec![0.0f64; vocab]; depth];
1038 let mut kv = caches(decoder);
1039 let logits = decoder.forward_batch(prompt, 0, &mut kv).pop().unwrap();
1040 let mut history = prompt.to_vec();
1041 walk(
1042 decoder,
1043 &kv,
1044 &logits,
1045 &mut history,
1046 1.0,
1047 0,
1048 depth,
1049 prompt.len(),
1050 params,
1051 &mut marginals,
1052 );
1053 marginals
1054 }
1055
1056 /// Speculation and plain token-at-a-time decoding must agree about
1057 /// WHICH tokens the penalties look back over, including the prompt.
1058 ///
1059 /// This is issue #55's other half. `SpeculativeOptions` used to
1060 /// carry a `penalty_history_start` knob whose only job was to let a
1061 /// caller line the two paths up by hand, which meant nothing failed
1062 /// when they drifted -- and `--model-draft` shipped setting it to
1063 /// `prompt.len()` because the plain loop penalised the generated
1064 /// tokens alone. Both now go through `PenaltyWindow`, and this test
1065 /// is what notices if one of them stops.
1066 ///
1067 /// Greedy on purpose: the assertion is token-for-token equality, so
1068 /// a one-token disagreement in the window is a hard failure rather
1069 /// than a shift in a sampled distribution. The prompt repeats
1070 /// tokens 1 and 2, so the penalty has something to bite on from the
1071 /// very first generated position.
1072 #[test]
1073 fn speculation_and_plain_decoding_penalise_the_same_window() {
1074 let cfg = tiny_test_config();
1075 let vocab = 6;
1076 let prompt = vec![0usize, 1, 2, 3, 1];
1077 let max_new = 6;
1078 let params = SamplingParams {
1079 temperature: 0.0,
1080 repetition_penalty: 3.0,
1081 penalty_last_n: 8,
1082 ..SamplingParams::default()
1083 };
1084
1085 let decoder = Decoder::new_random_small(cfg, 2, vocab);
1086
1087 // Plain token-at-a-time decoding, penalising over the same
1088 // window `Sampler::sample` would use on any decode loop.
1089 let mut kv = caches(&decoder);
1090 let mut pos = 0usize;
1091 let mut logits = Vec::new();
1092 for &tok in &prompt {
1093 logits = decoder.forward_token(tok, pos, &mut kv);
1094 pos += 1;
1095 }
1096 let mut sampler = Sampler::new(7);
1097 let mut plain: Vec<usize> = Vec::new();
1098 for _ in 0..max_new {
1099 let next = sampler.sample(&logits, ¶ms, PenaltyWindow::new(&prompt, &plain));
1100 plain.push(next);
1101 logits = decoder.forward_token(next, pos, &mut kv);
1102 pos += 1;
1103 }
1104
1105 let mut speculator = PromptLookupSpeculator::new(2, 3);
1106 let mut spec_kv = caches(&decoder);
1107 let out = speculative_decode_with(
1108 &decoder,
1109 &prompt,
1110 &mut spec_kv,
1111 &mut speculator,
1112 &SpeculativeOptions {
1113 max_new_tokens: max_new,
1114 sampling: params.clone(),
1115 seed: 7,
1116 ..SpeculativeOptions::default()
1117 },
1118 );
1119 assert_eq!(
1120 out.generated_tokens, plain,
1121 "speculation changed the text at --repeat-penalty {}",
1122 params.repetition_penalty
1123 );
1124
1125 // And the penalty is doing something here, or the equality
1126 // above is satisfied by a window nobody reads.
1127 let mut off = params.clone();
1128 off.repetition_penalty = 1.0;
1129 let mut kv = caches(&decoder);
1130 let mut pos = 0usize;
1131 let mut logits = Vec::new();
1132 for &tok in &prompt {
1133 logits = decoder.forward_token(tok, pos, &mut kv);
1134 pos += 1;
1135 }
1136 let mut sampler = Sampler::new(7);
1137 let mut unpenalised: Vec<usize> = Vec::new();
1138 for _ in 0..max_new {
1139 let next = sampler.sample(&logits, &off, PenaltyWindow::new(&prompt, &unpenalised));
1140 unpenalised.push(next);
1141 logits = decoder.forward_token(next, pos, &mut kv);
1142 pos += 1;
1143 }
1144 assert_ne!(
1145 unpenalised, plain,
1146 "the penalty must change this generation, or the agreement above proves nothing"
1147 );
1148 }
1149
1150 #[test]
1151 fn speculative_decode_at_temperature_matches_plain_sampling() {
1152 // The end-to-end half of the losslessness claim, and the one
1153 // the old argmax accept test fails: at temperature > 0 the
1154 // per-position output distribution of speculative decoding must
1155 // equal that of plain token-at-a-time sampling from the same
1156 // target. Argmax matching passes every other test in this file
1157 // and fails this one, because accepting a draft only when it is
1158 // the target's most likely token pushes mass onto the argmax.
1159 let cfg = tiny_test_config();
1160 let vocab = 6;
1161 let prompt = vec![1usize, 2, 3, 1, 2];
1162 let max_new = 3;
1163 let params = SamplingParams {
1164 temperature: 1.0,
1165 ..SamplingParams::default()
1166 };
1167 let seeds = 4_000u64;
1168
1169 let decoder = Decoder::new_random_small(cfg, 1, vocab);
1170 let mut speculator = PromptLookupSpeculator::new(2, 3);
1171 let exact = exact_marginals(&decoder, &prompt, ¶ms, max_new, vocab);
1172
1173 let mut spec_counts = vec![vec![0usize; vocab]; max_new];
1174 for seed in 0..seeds {
1175 let mut kv = caches(&decoder);
1176 let out = speculative_decode_with(
1177 &decoder,
1178 &prompt,
1179 &mut kv,
1180 &mut speculator,
1181 &SpeculativeOptions {
1182 max_new_tokens: max_new,
1183 sampling: params.clone(),
1184 seed,
1185 ..SpeculativeOptions::default()
1186 },
1187 );
1188 for (i, &t) in out.generated_tokens.iter().enumerate() {
1189 spec_counts[i][t] += 1;
1190 }
1191 }
1192
1193 for i in 0..max_new {
1194 let tv: f64 = (0..vocab)
1195 .map(|t| (spec_counts[i][t] as f64 / seeds as f64 - exact[i][t]).abs())
1196 .sum::<f64>()
1197 / 2.0;
1198 assert!(
1199 tv < 0.03,
1200 "position {i}: speculative sampling drifted from the target's own \
1201 distribution (total variation {tv:.4})\n speculative = {:?}\n exact = {:?}",
1202 spec_counts[i]
1203 .iter()
1204 .map(|&c| c as f64 / seeds as f64)
1205 .collect::<Vec<_>>(),
1206 exact[i]
1207 );
1208 }
1209 }
1210
1211 #[test]
1212 fn speculative_decode_saves_real_calls_when_drafts_hit() {
1213 let cfg = tiny_test_config();
1214 let vocab = 8;
1215 let prompt = vec![1usize, 2, 3, 1, 2];
1216 let max_new = 8;
1217
1218 let decoder = Decoder::new_random_small(cfg, 2, vocab);
1219 let mut kv = caches(&decoder);
1220 let mut speculator = PromptLookupSpeculator::new(2, 4);
1221 let result = speculative_decode(&decoder, &prompt, max_new, &mut kv, &mut speculator);
1222
1223 assert_eq!(result.tokens_generated, max_new);
1224 // Plain sequential decode needs exactly `max_new` calls here:
1225 // one prefill plus one per token except the last, whose KV is
1226 // never needed. Speculation must never need more.
1227 assert!(
1228 result.forward_calls <= max_new,
1229 "speculative decode must never need MORE forward_batch calls than plain \
1230 sequential decode would (calls={}, tokens={})",
1231 result.forward_calls,
1232 max_new
1233 );
1234 }
1235
1236 #[test]
1237 fn speculative_decode_with_no_repeats_falls_back_to_one_token_per_call() {
1238 // A prompt with no internal repeats at all must still work
1239 // correctly, just without any speedup.
1240 let cfg = tiny_test_config();
1241 let vocab = 8;
1242 let prompt = vec![1usize, 2, 3];
1243 let max_new = 5;
1244
1245 let decoder = Decoder::new_random_small(cfg, 2, vocab);
1246 let mut kv = caches(&decoder);
1247 let mut speculator = PromptLookupSpeculator::new(10, 4); // ngram far longer than any possible history
1248 let result = speculative_decode(&decoder, &prompt, max_new, &mut kv, &mut speculator);
1249
1250 assert_eq!(result.tokens_generated, max_new);
1251 assert_eq!(
1252 result.forward_calls,
1253 1 + max_new - 1,
1254 "prefill (1 call) + one call per token, minus the last token, whose KV is \
1255 never needed because generation stopped"
1256 );
1257 assert_eq!(result.drafted_tokens, 0);
1258 assert_eq!(result.accept_rate(), None);
1259 }
1260
1261 // ---- drafter trait plumbing ----
1262
1263 #[test]
1264 fn the_drafter_is_asked_to_continue_the_anchor_token() {
1265 // The block the drafter proposes lands *after* the pending
1266 // token, so the history it sees must already contain it.
1267 // Drafting from a history that stopped one token short would
1268 // shift every proposal by one position and quietly halve the
1269 // accept rate without breaking any output-correctness test.
1270 let cfg = tiny_test_config();
1271 let decoder = Decoder::new_random_small(cfg, 2, 8);
1272 let mut kv = caches(&decoder);
1273 let prompt = vec![1usize, 2, 3];
1274 let mut drafter = FixedDrafter::new(DraftBlock::deterministic(vec![5, 6]));
1275
1276 let result = speculative_decode(&decoder, &prompt, 4, &mut kv, &mut drafter);
1277
1278 let seen = drafter.seen_history.borrow();
1279 assert!(!seen.is_empty(), "the drafter must actually be consulted");
1280 for (round, history) in seen.iter().enumerate() {
1281 assert_eq!(
1282 history.len(),
1283 prompt.len() + round + 1,
1284 "round {round}: history must grow by the committed tokens"
1285 );
1286 assert_eq!(
1287 history[..prompt.len()],
1288 prompt[..],
1289 "the prompt must stay at the front of the drafter's history"
1290 );
1291 }
1292 assert_eq!(seen[0][prompt.len()], result.generated_tokens[0]);
1293 }
1294
1295 #[test]
1296 fn the_drafter_receives_the_targets_hidden_state() {
1297 // The conditioning tensor dFlash/EAGLE need. It is already
1298 // computed by verification; the trait exists so it stops being
1299 // discarded.
1300 let cfg = tiny_test_config();
1301 let hidden_dim = cfg.hidden_dim;
1302 let decoder = Decoder::new_random_small(cfg, 2, 8);
1303 let mut kv = caches(&decoder);
1304 let mut drafter = FixedDrafter::new(DraftBlock::deterministic(vec![5, 6]));
1305
1306 speculative_decode(&decoder, &[1usize, 2, 3], 4, &mut kv, &mut drafter);
1307
1308 let lens = drafter.seen_hidden_len.borrow();
1309 assert!(!lens.is_empty());
1310 for len in lens.iter() {
1311 assert_eq!(
1312 *len, hidden_dim,
1313 "every round must pass a full target hidden state, not an empty slice"
1314 );
1315 }
1316 }
1317
1318 // ---- cache resume + rollback arithmetic ----
1319
1320 #[test]
1321 fn resuming_a_warm_cache_gives_the_same_tokens_as_one_fresh_run() {
1322 // The serving shape: a prefix cache hands the decode loop a
1323 // cache that already holds part of the context.
1324 let cfg = tiny_test_config();
1325 let vocab = 8;
1326 let decoder = Decoder::new_random_small(cfg, 2, vocab);
1327 let mut speculator = PromptLookupSpeculator::new(2, 3);
1328 let full_prompt = vec![1usize, 2, 3, 4, 1, 2];
1329 let max_new = 6;
1330
1331 let mut fresh = caches(&decoder);
1332 let cold = speculative_decode(&decoder, &full_prompt, max_new, &mut fresh, &mut speculator);
1333
1334 // Warm: feed the first 4 prompt tokens through the decoder
1335 // first, then resume speculative decoding from position 4.
1336 let split = 4;
1337 let mut warm = caches(&decoder);
1338 decoder.forward_batch(&full_prompt[..split], 0, &mut warm);
1339 let resumed = speculative_decode_with(
1340 &decoder,
1341 &full_prompt[split..],
1342 &mut warm,
1343 &mut speculator,
1344 &SpeculativeOptions {
1345 max_new_tokens: max_new,
1346 start_pos: split,
1347 ..SpeculativeOptions::default()
1348 },
1349 );
1350
1351 assert_eq!(
1352 resumed.generated_tokens, cold.generated_tokens,
1353 "resuming a warm cache must not change the output"
1354 );
1355 }
1356
1357 #[test]
1358 fn rolls_back_to_absolute_positions_on_a_warm_cache() {
1359 // Rollback lengths are absolute cache lengths, not offsets from
1360 // the start of this call. With a warm cache the two differ by
1361 // start_pos, and a rollback that used the offset would truncate
1362 // into the caller's context. Forced rejections every round make
1363 // the rollback path run every round.
1364 let cfg = tiny_test_config();
1365 let decoder = Decoder::new_random_small(cfg, 2, 8);
1366 // Token 7 is a fixed guess; whether it is accepted is up to the
1367 // model, but the invariant below holds either way.
1368 let mut drafter = FixedDrafter::new(DraftBlock::deterministic(vec![7, 7, 7]));
1369 let context = vec![1usize, 2, 3, 4];
1370 let prompt = vec![5usize, 6];
1371 let max_new = 6;
1372
1373 let mut kv = caches(&decoder);
1374 decoder.forward_batch(&context, 0, &mut kv);
1375 assert_eq!(kv[0].positions(), context.len());
1376
1377 let result = speculative_decode_with(
1378 &decoder,
1379 &prompt,
1380 &mut kv,
1381 &mut drafter,
1382 &SpeculativeOptions {
1383 max_new_tokens: max_new,
1384 start_pos: context.len(),
1385 ..SpeculativeOptions::default()
1386 },
1387 );
1388
1389 assert_eq!(result.tokens_generated, max_new);
1390 // Exact invariant: the cache holds the context, the prompt and
1391 // every generated token except the last (whose KV is not
1392 // computed until it is fed).
1393 let expected = context.len() + prompt.len() + result.tokens_generated - 1;
1394 for cache in kv.iter() {
1395 assert_eq!(
1396 cache.positions(),
1397 expected,
1398 "cache length must be absolute: context {} + prompt {} + generated {} - 1",
1399 context.len(),
1400 prompt.len(),
1401 result.tokens_generated
1402 );
1403 }
1404 }
1405
1406 #[test]
1407 fn a_resumed_run_continues_a_previous_one() {
1408 // Two back-to-back calls on the same caches must equal one long
1409 // call: this is what "not a demo" means for the serving path.
1410 let cfg = tiny_test_config();
1411 let decoder = Decoder::new_random_small(cfg, 2, 8);
1412 let mut speculator = PromptLookupSpeculator::new(2, 3);
1413 let prompt = vec![1usize, 2, 3, 4, 1, 2];
1414
1415 let mut one = caches(&decoder);
1416 let long = speculative_decode(&decoder, &prompt, 8, &mut one, &mut speculator);
1417
1418 let mut kv = caches(&decoder);
1419 let first = speculative_decode(&decoder, &prompt, 4, &mut kv, &mut speculator);
1420 // The last generated token's KV is not in the cache yet, so it
1421 // is the first token of the continuation's "prompt".
1422 let resume_prompt = vec![*first.generated_tokens.last().unwrap()];
1423 let start = prompt.len() + first.tokens_generated - 1;
1424 let second = speculative_decode_with(
1425 &decoder,
1426 &resume_prompt,
1427 &mut kv,
1428 &mut speculator,
1429 &SpeculativeOptions {
1430 max_new_tokens: 5,
1431 start_pos: start,
1432 ..SpeculativeOptions::default()
1433 },
1434 );
1435
1436 let mut stitched = first.generated_tokens.clone();
1437 stitched.pop(); // re-fed as the continuation's prompt
1438 stitched.extend_from_slice(&second.generated_tokens);
1439 assert_eq!(
1440 &stitched[..8],
1441 &long.generated_tokens[..],
1442 "a decode split across two calls must equal the same decode in one"
1443 );
1444 }
1445
1446 #[test]
1447 #[should_panic(expected = "start_pos must be the caches' current length")]
1448 fn a_mismatched_start_pos_is_refused_rather_than_silently_wrong() {
1449 let cfg = tiny_test_config();
1450 let decoder = Decoder::new_random_small(cfg, 2, 8);
1451 let mut kv = caches(&decoder);
1452 decoder.forward_batch(&[1usize, 2, 3], 0, &mut kv);
1453 let mut speculator = PromptLookupSpeculator::new(2, 2);
1454 speculative_decode(&decoder, &[4usize, 5], 2, &mut kv, &mut speculator);
1455 }
1456
1457 // ---- acceptance metrics ----
1458
1459 #[test]
1460 fn per_position_accept_rates_expose_suffix_decay() {
1461 // A drafter whose first guess is always right and whose later
1462 // guesses are always wrong has the same mean accept rate as one
1463 // that is uniformly mediocre. Only the per-position curve tells
1464 // them apart, which is the whole reason it exists.
1465 let mut result = SpeculativeDecodeResult::default();
1466 for _ in 0..100 {
1467 result.record_position(0, true);
1468 result.record_position(1, false);
1469 }
1470 result.verification_steps = 100;
1471 result.tokens_generated = 200;
1472
1473 assert_eq!(result.accept_rate(), Some(0.5));
1474 assert_eq!(result.accept_rate_per_position(), vec![1.0, 0.0]);
1475 assert_eq!(result.acceptance_length(), Some(2.0));
1476 }
1477
1478 #[test]
1479 fn positions_after_a_rejection_are_not_counted_as_drafted() {
1480 // A round that rejects at position 0 never evaluates positions
1481 // 1..k. Counting them would report an accept rate that falls
1482 // with the block size rather than with the drafter's accuracy.
1483 let cfg = tiny_test_config();
1484 let decoder = Decoder::new_random_small(cfg, 2, 8);
1485 let mut kv = caches(&decoder);
1486 // Token 7 against a random model: whatever happens, every
1487 // counted position must have been reachable.
1488 let mut drafter = FixedDrafter::new(DraftBlock::deterministic(vec![7, 7, 7, 7]));
1489 let result = speculative_decode(&decoder, &[1usize, 2, 3], 6, &mut kv, &mut drafter);
1490
1491 let evaluated = &result.evaluated_at_position;
1492 let accepted = &result.accepted_at_position;
1493 assert!(
1494 result.drafted_tokens > result.accepted_tokens,
1495 "the scenario is pointless unless something was actually rejected \
1496 (drafted {}, accepted {})",
1497 result.drafted_tokens,
1498 result.accepted_tokens
1499 );
1500 // The sharp invariant: position i+1 is only reached when
1501 // position i was accepted, so it can never have been evaluated
1502 // more often. Merely checking that the counts are
1503 // non-increasing is not enough -- crediting every position of
1504 // every proposed block, rejected or not, keeps them
1505 // non-increasing (it makes them equal) while reporting an
1506 // accept rate that decays with the block size rather than with
1507 // the drafter.
1508 for (i, &seen) in evaluated.iter().enumerate().skip(1) {
1509 assert!(
1510 seen <= accepted[i - 1],
1511 "position {i} was evaluated {seen} times but position {} was only \
1512 accepted {} times: evaluated={evaluated:?} accepted={accepted:?}",
1513 i - 1,
1514 accepted[i - 1]
1515 );
1516 }
1517 assert_eq!(
1518 result.drafted_tokens,
1519 evaluated.iter().sum::<usize>(),
1520 "drafted_tokens must be the per-position counts' total"
1521 );
1522 assert_eq!(
1523 result.accepted_tokens,
1524 result.accepted_at_position.iter().sum::<usize>()
1525 );
1526 assert!(result.accepted_tokens <= result.drafted_tokens);
1527 }
1528
1529 #[test]
1530 fn acceptance_length_is_reported_per_verification_step_not_per_call() {
1531 // The published metric divides by verification steps; charging
1532 // the one-off prefill against it understates short runs.
1533 let cfg = tiny_test_config();
1534 let decoder = Decoder::new_random_small(cfg, 2, 8);
1535 let mut kv = caches(&decoder);
1536 let mut speculator = PromptLookupSpeculator::new(2, 3);
1537 let result =
1538 speculative_decode(&decoder, &[1usize, 2, 3, 1, 2], 6, &mut kv, &mut speculator);
1539
1540 assert_eq!(result.verification_steps, result.forward_calls - 1);
1541 let length = result.acceptance_length().unwrap();
1542 assert!(length >= 1.0, "every verification step commits >= 1 token");
1543 assert!(
1544 length > result.tokens_per_call(),
1545 "acceptance length must not be diluted by the prefill call"
1546 );
1547 }
1548
1549 #[test]
1550 fn an_empty_run_reports_no_acceptance_length_rather_than_zero() {
1551 let result = SpeculativeDecodeResult::default();
1552 assert_eq!(result.acceptance_length(), None);
1553 assert_eq!(result.accept_rate(), None);
1554 assert_eq!(result.tokens_per_call(), 0.0);
1555 }
1556
1557 /// The observer sees exactly the committed tokens, in order, and
1558 /// stopping through it truncates the result to the token that said
1559 /// so.
1560 ///
1561 /// This is what lets `frink run` stream and stop on an EOS without
1562 /// a second copy of the verification loop. A copy is how this
1563 /// project lost five model features from one duplicated decode
1564 /// path, and the rejection rule is the last code in the tree that
1565 /// should be duplicated: a subtly wrong copy still looks lossless.
1566 #[test]
1567 fn the_observer_sees_every_committed_token_and_can_end_the_run() {
1568 let cfg = tiny_test_config();
1569 let vocab = 8;
1570 let prompt = vec![1usize, 2, 3, 4, 1, 2];
1571
1572 let decoder = Decoder::new_random_small(cfg.clone(), 2, vocab);
1573 let mut kv = caches(&decoder);
1574 let mut spec = PromptLookupSpeculator::new(2, 3);
1575
1576 let mut seen = Vec::new();
1577 let result = speculative_decode_observed(
1578 &decoder,
1579 &prompt,
1580 &mut kv,
1581 &mut spec,
1582 &mut |t| {
1583 seen.push(t);
1584 true
1585 },
1586 &SpeculativeOptions {
1587 max_new_tokens: 6,
1588 start_pos: 0,
1589 sampling: SamplingParams::default(),
1590 seed: 0,
1591 },
1592 );
1593 assert_eq!(
1594 seen, result.generated_tokens,
1595 "the observer must see exactly what the run returns, in order"
1596 );
1597
1598 // Now stop after three tokens.
1599 let decoder = Decoder::new_random_small(cfg, 2, vocab);
1600 let mut kv = caches(&decoder);
1601 let mut spec = PromptLookupSpeculator::new(2, 3);
1602 let mut count = 0usize;
1603 let stopped = speculative_decode_observed(
1604 &decoder,
1605 &prompt,
1606 &mut kv,
1607 &mut spec,
1608 &mut |_| {
1609 count += 1;
1610 count < 3
1611 },
1612 &SpeculativeOptions {
1613 max_new_tokens: 6,
1614 start_pos: 0,
1615 sampling: SamplingParams::default(),
1616 seed: 0,
1617 },
1618 );
1619 assert_eq!(
1620 stopped.generated_tokens.len(),
1621 3,
1622 "the run must end at the token that said stop, not at the end of its block"
1623 );
1624 assert_eq!(
1625 stopped.generated_tokens,
1626 result.generated_tokens[..3],
1627 "and the tokens up to the stop must be the ones an unstopped run produced"
1628 );
1629 }
1630}