Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
Speculative Decoding: Accept Step
Speculative decoding makes LLM inference faster: a small draft model proposes several tokens, and the big target model verifies them in one pass. The accept/reject rule is a clever bit of probability that guarantees the output matches the target model’s own distribution. Implement it. Solve it in Python or TypeScript, with hidden tests.
The problem
A draft model proposed a run of tokens; for each, you have the target-model probability p[i] and the draft-model probability q[i] it was sampled from, plus a uniform random number r[i] in [0, 1). Accept token i if r[i] ≤ min(1, p[i] / q[i]); stop at the first rejection. Implement spec_accept(p, q, r): return how many proposed tokens are accepted before the first rejection.
Your turn — write it
Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.
Implement spec_accept(p, q, r): iterate the proposed tokens; accept while r[i] ≤ min(1, p[i]/q[i]), and break on the first token that fails. Return the number accepted.
Examples and constraints 4 examples
p = [0.9, 0.2, 0.8], q = [0.5, 0.8, 0.4], r = [0.1, 0.5, 0.2]1p = [0.5, 0.5], q = [0.5, 0.5], r = [0.3, 0.9]2p = [0.1], q = [0.9], r = [0.5]0p = [0.5], q = [1.0], r = [0.5]1- Accept token i iff
r[i] ≤ min(1, p[i] / q[i]). When the target likes the token at least as much as the draft (ratio ≥ 1), it is always accepted. - Stop at the first rejection — tokens after a rejected one are discarded (the target model resamples from there).
- Return the count of accepted tokens (the length of the accepted prefix). This acceptance rule is exactly what makes the sped-up output identical in distribution to plain target-model sampling.
Hints 4 ideas
- For each token compute the acceptance threshold
min(1, p[i] / q[i]). - If the target probability is ≥ the draft probability, the ratio is ≥ 1, so the token is always accepted.
- Compare the given random draw: accept if
r[i] ≤ threshold, otherwise reject. - The moment a token is rejected, stop — return the count so far. Everything after is thrown away.
Approach, complexity & discussion — open after you solve
Start from brute force
The obvious first draft tests every drafted token on its own and counts how many pass: sum(r[i] <= min(1, p[i] / q[i]) for i in range(len(p))). Each individual test is the right one, and it costs O(k) time for k drafted tokens and O(1) space, so speed is not the issue: it is wrong. On the first example it returns 2, because token 2 passes on its own with a ratio of 2.0, but the answer is 1. Token 2 was drafted as the continuation of token 1, and token 1 was rejected, so token 2 continues a prefix the output will never contain. Only an unbroken run of acceptances from the start counts.
The approach
Read the draft as a prefix: token i only means something if every token before it was kept. So scan left to right and stop at the first rejection. Each test accepts token i with probability min(1, p[i] / q[i]): certainly when the target likes it at least as much as the draft did, and in proportion when the draft was over-confident. Combined with resampling at the rejected position, which happens outside this function, that rule makes every emitted token follow the target model’s distribution exactly.
The reference solution keeps one counter, accepted. For each i it computes threshold = min(1.0, p[i] / q[i]); if r[i] <= threshold it adds 1, otherwise it hits break, so the tokens after a rejection are never even examined. It returns accepted, the length of the accepted prefix. Supplying the uniform draws as r makes the result deterministic: a real serving loop draws them at random, and because r is uniform on [0, 1), r[i] <= threshold happens with probability exactly threshold.
Walk through an example
Trace the first example, spec_accept([0.9, 0.2, 0.8], [0.5, 0.8, 0.4], [0.1, 0.5, 0.2]), one drafted token per row.
| step | p[i] / q[i] | threshold | r[i] | decision | accepted |
|---|---|---|---|---|---|
| i = 0 | 1.8 | 1.0 | 0.1 | 0.1 ≤ 1.0: keep | 1 |
| i = 1 | 0.25 | 0.25 | 0.5 | 0.5 > 0.25: reject, break | 1 |
| i = 2 | (2.0) | — | 0.2 | never examined | 1 |
| return | — | — | — | the accepted prefix has length 1 | 1 |
Complexity
Optimal: O(a + 1) time, where a is the number accepted, because the loop stops at the first rejection, so at most O(k) for k drafted tokens; O(1) space for the counter. Brute force (count every token that passes) is O(k) time and O(1) space: wrong, not slow.
Edge cases to test
- A ratio of 1 or more always accepts:
spec_accept([0.9, 0.9], [0.3, 0.3], [0.99, 0.99])→2, since everyris below 1. - The boundary is inclusive:
spec_accept([0.5], [1.0], [0.5])→1, becauserequals the threshold and the test is<=. - Rejecting the first token:
spec_accept([0.1], [0.9], [0.5])→0. Nothing from the draft is kept, but the pass still produces one token, because the target resamples that position. - An empty draft:
spec_accept([], [], [])→0. - A zero draft probability: Python raises
ZeroDivisionError, while in TypeScript0.5 / 0isInfinity, so the token is accepted, and0 / 0isNaN, so it is rejected. A token sampled from the draft always hasq > 0, but the division-free testr[i] * q[i] <= p[i]avoids the question. - A later token that would pass is still discarded: in the first example token 2 has a ratio of 2.0 but comes after a rejection, so the answer stays 1.
Common mistakes
- Accepting on the raw target probability instead of the
p_target / p_draftratio — that changes the output distribution. - On rejection, sampling from the plain target distribution instead of the residual
max(0, p − q), renormalized over the vocabulary. - Keeping tokens that came after a rejected one.
- Using
<instead of<=: harmless with continuous random draws, but it fails the inclusive boundary that the statement specifies.
Follow-ups an interviewer asks next
- What happens after the first rejection?
- The target model resamples that position from the residual distribution
max(0, p − q), renormalized, which needs the full probability vectors from both models, not just the sampled token’s entries. If every drafted token is accepted, it samples one bonus token from its own next-position distribution. Either way, one target pass yields between 1 andk + 1tokens. - Why does the output match the target distribution exactly?
- The chance of emitting token
xby acceptance isq(x) · min(1, p(x)/q(x)) = min(p(x), q(x)). Rejection happens with probability1 − Σ min(p, q), which equalsΣ max(0, p − q), so resampling from the normalized residual adds exactlymax(0, p(x) − q(x)). The two parts sum top(x). - How many tokens does one target pass produce on average?
- If each drafted token is accepted independently with probability
αand the draft hasktokens, the expected number of tokens per target pass is(1 − α^(k+1)) / (1 − α), the formula from Leviathan et al. Withα = 0.8andk = 4that is about 3.36. A longer draft only helps while the draft model keeps agreeing with the target. - What does the rule become under greedy decoding?
- With temperature 0 the target distribution puts all its mass on one token, so a drafted token is accepted exactly when it equals the target’s argmax at that position. Verification stops at the first mismatch and emits the target’s own argmax there. The output is then token for token identical to plain greedy decoding with the target model.
Where this shows up
This accept/reject rule is what makes speculative decoding a provably lossless speedup — the output distribution is identical to the target model’s. Leviathan et al., who introduced it, reported a 2–3× speedup on T5-XXL with identical outputs; the gain in any given deployment depends on how often the draft model agrees with the target. It is the core trick behind fast LLM serving.
Reference solution
Reference solution — Python
def spec_accept(p, q, r):
accepted = 0
for i in range(len(p)):
threshold = min(1.0, p[i] / q[i]) # accept probability for this token
if r[i] <= threshold:
accepted += 1 # keep it
else:
break # first rejection stops the run
return accepted
print(spec_accept([0.9, 0.2, 0.8], [0.5, 0.8, 0.4], [0.1, 0.5, 0.2])) # -> 1
print(spec_accept([0.5, 0.5], [0.5, 0.5], [0.3, 0.9])) # -> 2Reference solution — TypeScript
function specAccept(p: number[], q: number[], r: number[]): number {
let accepted = 0;
for (let i = 0; i < p.length; i++) {
const threshold = Math.min(1, p[i] / q[i]); // acceptance probability
if (r[i] <= threshold) accepted++; // keep it
else break; // first rejection stops
}
return accepted;
}
console.log(specAccept([0.9, 0.2, 0.8], [0.5, 0.8, 0.4], [0.1, 0.5, 0.2])); // -> 1
console.log(specAccept([0.5, 0.5], [0.5, 0.5], [0.3, 0.9])); // -> 2Questions about Speculative Decoding: Accept Step
How does speculative decoding speed up LLM inference?
A large language model generates one token per forward pass, and at small batch sizes each pass is limited more by moving weights through memory than by arithmetic, so checking several tokens in one pass costs about as much as generating one. Speculative decoding exploits that: a small, fast draft model proposes several tokens, and the large target model scores all of them in a single pass. Every drafted token that passes the acceptance test is a token the target did not have to generate on its own, so when the draft usually agrees with the target, each expensive pass yields several tokens instead of one.
Is speculative decoding lossless?
Yes, when it uses this acceptance rule with resampling from the residual distribution. Each drafted token is accepted with probability min(1, p over q), and a rejected position is resampled from the normalized positive part of p minus q. Working through the probabilities shows that every emitted token is distributed exactly as if the target model had sampled it directly, so quality is unchanged and only speed differs. Under greedy decoding the guarantee is even simpler: the output is token for token identical to the target model’s own greedy output.
Why accept a draft token with probability min(1, p/q)?
Because it corrects for the draft model’s bias. If the draft proposed a token with probability q and the target assigns it probability p, the target wants that token emitted with probability p overall. Accepting with probability min(1, p over q) means the token is emitted by acceptance with probability q times that, which is the smaller of p and q. Where the draft was too eager, with q above p, this trims the excess; where it was too shy, the shortfall is made up later by resampling from the residual distribution. The two cases together give exactly p.
What happens when a speculative decoding draft token is rejected?
Everything from that token onward is thrown away, because each later draft token was generated as a continuation of the rejected one. The target model then samples a replacement for the rejected position from the residual distribution, the positive part of its own probabilities minus the draft’s, renormalized to sum to one. Decoding continues from that new token with a fresh draft. This challenge implements only the counting part: it returns how many tokens were accepted before the first rejection, which is the length of the prefix the output keeps.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- SoftmaxThe function at the end of every classifier and language model: turn raw scores (logits) into a probability distribution. Implement the numerically stable version so big logits do not overflow. Solve it in Python or TypeScript.Read →
- Single-Head AttentionThe operation at the heart of every Transformer: scaled dot-product attention. Given queries, keys, and values, let each query pull a weighted blend of the values — softmax(QKᵀ/√d)·V — with a numerically stable softmax and no numpy, just the math. Solve it in Python or TypeScript.Read →
- Layer NormalizationThe stabilizer wrapped around every Transformer sub-layer: re-center and re-scale a vector to mean 0 and variance 1, then let learned gamma and beta stretch and shift it back — y = gamma·(x−mean)/√(var+eps) + beta. Keeps deep nets trainable. Solve it in Python or TypeScript.Read →
- Cross-Entropy LossThe loss that trains almost every classifier and language model. It measures how surprised the model was by the right answer — high probability on the true class → near 0, confidently wrong → explodes. Return -log(p[target]), eps-guarded. Solve it in Python or TypeScript.Read →
- TF-IDFThe scoring that ran search for decades — and still seeds hybrid retrieval today. Reward a word frequent in one document but rare across the corpus, shrug off words that appear everywhere: tf·log(N/df). Solve it in Python or TypeScript.Read →
- BPE Merge StepOne step of how every tokenizer vocabulary is built: count adjacent symbol pairs across the corpus, pick the most frequent (ties break lexicographically), and merge it everywhere, left to right, without overlaps. Run it a few thousand times and you have byte-pair encoding. Solve it in Python or TypeScript.Read →