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