Skip to main content

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