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