use crate::config::ModelConfig;
use crate::decoder::Decoder;
use crate::sampling::{sampling_distribution, Sampler, SamplingParams};
use crate::speculative::{DraftBlock, DraftDist, Drafter};
use ferrox_core::cache::KvCache;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum VocabMismatch {
#[error(
"draft and target checkpoints disagree about vocabulary size ({draft} vs {target}), so a \
draft token id does not name the same token in both. Speculative decoding compares the \
drafter's probability for a token id against the target's probability for that same id, \
which would be comparing unrelated tokens: the result would not be the target's \
distribution, and it would look exactly like text that is. Use a draft model from the \
same family and tokenizer as the target"
)]
Size { draft: usize, target: usize },
}
pub struct DraftModelSpeculator {
decoder: Decoder,
kv_caches: Vec<KvCache>,
synced: usize,
sampling: SamplingParams,
rng: Sampler,
min_prob: f32,
max_draft: usize,
}
impl DraftModelSpeculator {
pub fn new(
decoder: Decoder,
target_config: &ModelConfig,
sampling: SamplingParams,
seed: u64,
max_draft: usize,
min_prob: f32,
) -> Result<Self, VocabMismatch> {
let draft_vocab = decoder.config.vocab_size;
let target_vocab = target_config.vocab_size;
if draft_vocab != target_vocab {
return Err(VocabMismatch::Size {
draft: draft_vocab,
target: target_vocab,
});
}
let kv_caches = (0..decoder.config.n_layers)
.map(|_| KvCache::new(decoder.config.n_kv_heads, decoder.config.head_dim))
.collect();
Ok(DraftModelSpeculator {
decoder,
kv_caches,
synced: 0,
sampling,
rng: Sampler::new(seed),
min_prob,
max_draft,
})
}
fn truncate_to(&mut self, len: usize) {
for cache in &mut self.kv_caches {
cache.truncate(len);
}
}
fn sync(&mut self, history: &[usize]) -> Vec<f32> {
debug_assert!(
!history.is_empty(),
"callers return early on an empty history"
);
let keep = self.synced.min(history.len() - 1);
self.truncate_to(keep);
self.synced = keep;
let mut logits = Vec::new();
while self.synced < history.len() {
let pos = self.synced;
logits = self
.decoder
.forward_token(history[pos], pos, &mut self.kv_caches);
self.synced += 1;
}
logits
}
}
impl Drafter for DraftModelSpeculator {
fn propose(&mut self, history: &[usize], _target_hidden: &[f32], max_len: usize) -> DraftBlock {
let budget = max_len.min(self.max_draft);
if budget == 0 || history.is_empty() {
return DraftBlock::empty();
}
let mut logits = self.sync(history);
let mut tokens = Vec::with_capacity(budget);
let mut dists = Vec::with_capacity(budget);
let mut local: Vec<usize> = history.to_vec();
for _ in 0..budget {
let probs = sampling_distribution(&logits, &self.sampling, &local);
let token = self.rng.sample_from(&probs);
let dist = DraftDist::from_dense(&probs);
let q = dist.prob(token);
if q < self.min_prob {
break;
}
tokens.push(token);
dists.push(dist);
local.push(token);
let pos = self.synced;
logits = self.decoder.forward_token(token, pos, &mut self.kv_caches);
self.synced += 1;
}
DraftBlock::new(tokens, dists)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::test_dense_fixture;
fn drafter(vocab: usize, max_draft: usize, min_prob: f32) -> DraftModelSpeculator {
let target = {
let mut c = test_dense_fixture();
c.vocab_size = vocab;
c
};
let decoder = Decoder::new_random_small(test_dense_fixture(), 2, vocab);
DraftModelSpeculator::new(
decoder,
&target,
SamplingParams::default(),
7,
max_draft,
min_prob,
)
.expect("matching vocabularies")
}
#[test]
fn a_draft_model_with_a_different_vocabulary_is_refused_by_name() {
let mut target = test_dense_fixture();
target.vocab_size = 64;
let decoder = Decoder::new_random_small(test_dense_fixture(), 2, 32);
let err = DraftModelSpeculator::new(decoder, &target, SamplingParams::default(), 0, 4, 0.0)
.err()
.expect("32 != 64");
assert_eq!(
err,
VocabMismatch::Size {
draft: 32,
target: 64
}
);
let msg = err.to_string();
assert!(msg.contains("32") && msg.contains("64"), "{msg}");
assert!(msg.contains("same family and tokenizer"), "{msg}");
}
#[test]
fn a_block_carries_one_honest_distribution_per_drafted_token() {
let mut d = drafter(32, 4, 0.0);
let block = d.propose(&[1, 2, 3], &[], 4);
assert_eq!(block.len(), 4, "the whole budget was drafted");
assert_eq!(block.tokens().len(), block.dists().len());
for (token, dist) in block.tokens().iter().zip(block.dists()) {
assert!(
dist.prob(*token) > 0.0,
"a drafter must report the distribution it sampled from"
);
}
}
#[test]
fn the_draft_cache_rolls_back_the_positions_the_target_did_not_accept() {
let mut d = drafter(32, 4, 0.0);
let block = d.propose(&[1, 2, 3], &[], 4);
assert_eq!(block.len(), 4);
assert_eq!(
d.synced, 7,
"3 of history plus 4 drafted are in the cache after proposing"
);
d.propose(&[1, 2, 3, block.tokens()[0]], &[], 4);
assert_eq!(
d.kv_caches[0].seq_len, d.synced,
"every layer's cache agrees with the drafter's own count"
);
assert_eq!(
d.synced, 8,
"4 committed tokens plus 4 freshly drafted, NOT 7 stale rows plus more"
);
}
#[test]
fn a_history_shorter_than_the_cache_truncates_rather_than_underflowing() {
let mut d = drafter(32, 4, 0.0);
d.propose(&[1, 2, 3, 4, 5], &[], 4);
assert_eq!(d.synced, 9);
d.propose(&[1, 2], &[], 1);
assert_eq!(d.synced, 3, "2 of history plus 1 drafted");
assert_eq!(d.kv_caches[0].seq_len, 3);
}
#[test]
fn a_drafter_below_the_probability_floor_proposes_nothing() {
let mut d = drafter(32, 4, 1.01);
let block = d.propose(&[1, 2, 3], &[], 4);
assert!(block.is_empty(), "nothing clears a floor above 1.0");
assert_eq!(
d.synced, 3,
"and the cache holds the history only, no abandoned draft rows"
);
}
#[test]
fn the_configured_maximum_bounds_the_callers_budget() {
let mut d = drafter(32, 2, 0.0);
assert_eq!(d.propose(&[1, 2, 3], &[], 8).len(), 2);
}
#[test]
fn an_empty_history_or_a_zero_budget_proposes_nothing() {
let mut d = drafter(32, 4, 0.0);
assert!(d.propose(&[], &[], 4).is_empty());
assert!(d.propose(&[1, 2], &[], 0).is_empty());
}
#[test]
fn a_draft_model_does_not_change_what_the_target_writes() {
use crate::speculative::speculative_decode;
let cfg = test_dense_fixture();
let vocab = 32;
let prompt = vec![1usize, 2, 3, 4, 1, 2];
let max_new = 8;
let target = Decoder::new_random_small(cfg.clone(), 4, vocab);
let mut caches: Vec<KvCache> = (0..target.config.n_layers)
.map(|_| KvCache::new(target.config.n_kv_heads, target.config.head_dim))
.collect();
let draft = Decoder::new_random_small(cfg.clone(), 2, vocab);
let mut drafter =
DraftModelSpeculator::new(draft, &target.config, SamplingParams::default(), 11, 4, 0.0)
.expect("matching vocabularies");
let result = speculative_decode(&target, &prompt, max_new, &mut caches, &mut drafter);
let plain = Decoder::new_random_small(cfg, 4, vocab);
let mut plain_caches: Vec<KvCache> = (0..plain.config.n_layers)
.map(|_| KvCache::new(plain.config.n_kv_heads, plain.config.head_dim))
.collect();
let mut pending = plain
.forward_batch(&prompt, 0, &mut plain_caches)
.pop()
.expect("a non-empty prompt returns logits");
let mut greedy = Vec::with_capacity(max_new);
for pos in (prompt.len()..).take(max_new) {
let tok = pending
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).expect("logits are finite"))
.map(|(i, _)| i)
.expect("a non-empty vocabulary");
greedy.push(tok);
pending = plain.forward_token(tok, pos, &mut plain_caches);
}
assert_eq!(
result.generated_tokens, greedy,
"a draft model may make decoding faster and may not make it different"
);
}
#[test]
fn the_reported_distribution_is_the_one_sampled_from_at_temperature() {
let target = {
let mut c = test_dense_fixture();
c.vocab_size = 32;
c
};
let decoder = Decoder::new_random_small(test_dense_fixture(), 2, 32);
let sampling = SamplingParams {
temperature: 1.0,
..SamplingParams::default()
};
let mut d = DraftModelSpeculator::new(decoder, &target, sampling.clone(), 3, 4, 0.0)
.expect("matching vocabularies");
let block = d.propose(&[1, 2, 3], &[], 4);
assert_eq!(block.len(), 4);
let spread = block.dists().iter().any(|dist| dist.support().len() > 1);
assert!(
spread,
"at temperature 1.0 a real model's draft distribution is not a point mass; if it were, this test could not tell an honest report from a lie"
);
for (token, dist) in block.tokens().iter().zip(block.dists()) {
let q = dist.prob(*token);
assert!(q > 0.0, "the sampled token must be in its own support");
assert!(
q < 1.0,
"a spread distribution reported as certainty is the lie this test exists for"
);
let total: f32 = dist.support().iter().map(|&(_, p)| p).sum();
assert!(
(total - 1.0).abs() < 1e-4,
"a reported distribution must be normalised, got {total}"
);
}
}
}