Skip to content

Commit ff19ed3

Browse files
afflomclaude
andcommitted
refactor(speculative): drop the n-gram cap — draft from the longest recurring suffix, in O(n)
The prompt-lookup drafter carried a tuning constant: `speculative_ngram`, defaulting to 3. That number is a guess about how much context an ARBITRARY input recurs over. Code, JSON, retrieval and format-echoing recur over far more than three tokens, and every token of context beyond the cap was silently discarded — acceptance left on the floor because of a hard-coded 3. The correct context is not a tunable: it is the LONGEST suffix of the realized sequence that occurs earlier in it at all. That is now what the drafter uses, with the most recent such occurrence breaking ties, and no cap anywhere. The old search was also `O(n² · g)` — a scan per n-gram length, backwards over the sequence — which an arbitrarily long realized sequence simply cannot afford. The new search is `O(n)` time and space: the Z-function of the REVERSED sequence gives, for every earlier end position, the length of the longest common suffix, so one ascending pass yields the longest recurrence tie-broken toward the freshest occurrence. `PromptLookupDrafter` is now a unit struct with no parameters. `GenOpts. speculative_ngram` is removed from the wasm surface and from `holo.ts`; the conformance harness drops `SPEC_NGRAM`. Nothing else changes: the drafter only proposes, the target verifies every drafted token, so the output is the target's byte for byte — only the acceptance rate moves, and it moves up. V&V: the drafter's unit suite keeps its existing semantics (longest suffix wins; most recent occurrence among equals; max_draft caps the length; a novel suffix drafts nothing) and gains two that the cap made impossible — `a_longer_context_beats_a_more_recent_shorter_one` (a 3-token context whose continuation differs from the more-recent 2-token one) and `the_drafter_has_no_context_cap_and_stays_linear` (a 4096-token recurrence matched in full, which a cap of 3 would have truncated). `z_function` is checked against a naive reference. Row `speculative-decode` prose carries the law. Gates: fmt, clippy -D warnings, conformance 91/91 (greedy/sampled/draft-model byte-identity, the recurring-stretch fewer-passes bound, and the bucket-boundary scenario all unchanged), tsc, browser BDD 28/28. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 12025b3 commit ff19ed3

5 files changed

Lines changed: 118 additions & 106 deletions

File tree

apps/web/src/holo.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -408,8 +408,6 @@ export interface GenOpts {
408408
* the accept rule samples per absolute position, so the output is
409409
* byte-identical to plain decode at that temperature. */
410410
speculative_draft?: number;
411-
/** The drafter's max n-gram context length (default 3 when speculating). */
412-
speculative_ngram?: number;
413411
}
414412

415413
/**

crates/hologram-ai-conformance/tests/bdd.rs

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4298,7 +4298,6 @@ fn spec_decode_session(store: &Path, bucket: u64) -> DecodeSession<HoloRunner> {
42984298
DecodeSession::new(runner, theta, STG_WINDOW).expect("the decode session opens")
42994299
}
43004300

4301-
const SPEC_NGRAM: usize = 3;
43024301
const SPEC_DRAFT: usize = 4; // the verify runner's chunk K
43034302
const SPEC_MAX_TOKENS: usize = 48; // long enough for the fixture's cycle to dominate
43044303

@@ -4341,9 +4340,7 @@ async fn when_spec_vs_plain(w: &mut BddWorld) {
43414340
&DecimalTok,
43424341
prompt,
43434342
&cfg,
4344-
&mut hologram_ai::speculative::PromptLookupDrafter {
4345-
ngram_max: SPEC_NGRAM,
4346-
},
4343+
&mut hologram_ai::speculative::PromptLookupDrafter,
43474344
SPEC_DRAFT,
43484345
&mut sink,
43494346
)
@@ -4391,9 +4388,7 @@ async fn when_spec_vs_plain_sampled(w: &mut BddWorld, temperature: f64) {
43914388
&DecimalTok,
43924389
prompt,
43934390
&cfg,
4394-
&mut hologram_ai::speculative::PromptLookupDrafter {
4395-
ngram_max: SPEC_NGRAM,
4396-
},
4391+
&mut hologram_ai::speculative::PromptLookupDrafter,
43974392
SPEC_DRAFT,
43984393
&mut sink,
43994394
)
@@ -4490,9 +4485,7 @@ async fn when_spec_passes(w: &mut BddWorld) {
44904485
&DecimalTok,
44914486
"1",
44924487
&cfg,
4493-
&mut hologram_ai::speculative::PromptLookupDrafter {
4494-
ngram_max: SPEC_NGRAM,
4495-
},
4488+
&mut hologram_ai::speculative::PromptLookupDrafter,
44964489
SPEC_DRAFT,
44974490
&mut sink,
44984491
)
@@ -4550,9 +4543,7 @@ async fn when_spec_across_boundary(w: &mut BddWorld) {
45504543
&DecimalTok,
45514544
"1",
45524545
&cfg,
4553-
&mut hologram_ai::speculative::PromptLookupDrafter {
4554-
ngram_max: SPEC_NGRAM,
4555-
},
4546+
&mut hologram_ai::speculative::PromptLookupDrafter,
45564547
SPEC_DRAFT,
45574548
&mut sink,
45584549
)

crates/hologram-ai-wasm/src/lib.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -981,8 +981,6 @@ pub struct GenOpts {
981981
/// absolute position, so the output is byte-identical to plain decode at
982982
/// that temperature (greedy when temperature ≤ 0).
983983
pub speculative_draft: Option<usize>,
984-
/// The drafter's max n-gram context length (default 3 when speculating).
985-
pub speculative_ngram: Option<usize>,
986984
}
987985

988986
impl GenOpts {
@@ -1652,7 +1650,6 @@ impl DecodeChatSession {
16521650
// never meaning.
16531651
let draft = opts.speculative_draft.unwrap_or(0);
16541652
if draft >= 2 {
1655-
let ngram = opts.speculative_ngram.unwrap_or(3);
16561653
let bucket = session.geometry().bucket;
16571654
match self
16581655
.growable
@@ -1683,8 +1680,7 @@ impl DecodeChatSession {
16831680
self.draft_session = Some(drafter.into_session());
16841681
r
16851682
} else {
1686-
let mut drafter =
1687-
hologram_ai::speculative::PromptLookupDrafter { ngram_max: ngram };
1683+
let mut drafter = hologram_ai::speculative::PromptLookupDrafter;
16881684
hologram_ai::commands::generate::generate_stream_speculative(
16891685
session,
16901686
&mut verify,

crates/hologram-ai/src/speculative.rs

Lines changed: 109 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@
1515
//! so a drafter changes the acceptance rate, never the output:
1616
//!
1717
//! - **prompt-lookup** ([`PromptLookupDrafter`]): the next tokens are guessed by
18-
//! finding the most recent earlier occurrence of the current suffix in the
19-
//! realized sequence and copying what followed it. No draft model, no
20-
//! training, no weights — parametric over any model and any input. It shines
18+
//! finding the most recent earlier occurrence of the LONGEST recurring suffix
19+
//! of the realized sequence and copying what followed it. No draft model, no
20+
//! training, no weights, and no n-gram cap — parametric over any model and any
21+
//! input, at O(n) per draft. It shines
2122
//! on structured/repetitive text (code, JSON, retrieval, format echoing) and
2223
//! returns nothing on novel text, so the caller falls back to one plain step.
2324
//! - **draft model** ([`ModelDrafter`]): a small second model (sharing the
@@ -92,14 +93,14 @@ pub fn draft_pairing_refusal(
9293
}
9394

9495
/// The zero-weight prompt-lookup drafter (the shipped default): stateless — it
95-
/// reads the realized sequence and needs no prefill or commit.
96-
pub struct PromptLookupDrafter {
97-
pub ngram_max: usize,
98-
}
96+
/// reads the realized sequence and needs no prefill or commit. It carries no
97+
/// tuning parameter: the context it drafts from is the LONGEST recurrence the
98+
/// sequence itself contains.
99+
pub struct PromptLookupDrafter;
99100

100101
impl Drafter for PromptLookupDrafter {
101102
fn propose(&mut self, realized: &[i64], cap: usize) -> Result<Vec<i64>> {
102-
Ok(prompt_lookup_draft(realized, self.ngram_max, cap))
103+
Ok(prompt_lookup_draft(realized, cap))
103104
}
104105
fn commit(&mut self, _accepted: usize, _bonus: i64) -> Result<()> {
105106
Ok(())
@@ -187,35 +188,76 @@ fn argmax(row: &[f32]) -> u32 {
187188

188189
/// Draft up to `max_draft` continuation tokens for `seq` by prompt-lookup.
189190
///
190-
/// For the largest suffix length `g ∈ [1, ngram_max]` that occurs earlier in
191-
/// `seq`, take the most recent such earlier occurrence and copy the up-to-
192-
/// `max_draft` tokens that followed it. A longer matched suffix is a more
193-
/// specific context, so it is tried first (higher acceptance). Returns an empty
194-
/// draft when nothing matches — the signal to fall back to a single decode step.
191+
/// The drafting context is the LONGEST suffix of `seq` that occurs earlier in
192+
/// `seq` at all — there is no n-gram cap, because a cap is a guess about how
193+
/// much context an arbitrary input recurs over. Among earlier occurrences of
194+
/// that longest suffix the MOST RECENT is used (the freshest continuation), and
195+
/// the up-to-`max_draft` tokens that followed it are the draft. Returns an empty
196+
/// draft when the suffix never recurs — the signal to fall back to a single
197+
/// decode step.
198+
///
199+
/// Found in O(n) time and space with the Z-function of the REVERSED sequence:
200+
/// `z[i]` is the longest common prefix of `rev` and `rev[i..]`, i.e. the longest
201+
/// common SUFFIX of `seq` and `seq[..n-i]` — an earlier occurrence ending at
202+
/// `n - i`. Scanning `i` ascending and keeping strictly longer matches yields the
203+
/// longest suffix, tie-broken toward the most recent occurrence. (The former
204+
/// capped scan was `O(n² · g)`, which an arbitrarily long realized sequence
205+
/// cannot afford.)
195206
///
196207
/// This never affects correctness, only speed: every drafted token is verified
197208
/// and a wrong one is rejected. It is a pure function of the realized sequence.
198-
pub fn prompt_lookup_draft(seq: &[i64], ngram_max: usize, max_draft: usize) -> Vec<i64> {
209+
pub fn prompt_lookup_draft(seq: &[i64], max_draft: usize) -> Vec<i64> {
199210
let n = seq.len();
200-
if n < 2 || max_draft == 0 || ngram_max == 0 {
211+
if n < 2 || max_draft == 0 {
201212
return Vec::new();
202213
}
203-
// Longest matching suffix first: a more specific context accepts better.
204-
let g_max = ngram_max.min(n - 1);
205-
for g in (1..=g_max).rev() {
206-
let needle = &seq[n - g..];
207-
// Most recent earlier occurrence first (skip the trailing suffix itself).
208-
for start in (0..n - g).rev() {
209-
if &seq[start..start + g] == needle {
210-
let from = start + g;
211-
let take = max_draft.min(n - from);
212-
if take > 0 {
213-
return seq[from..from + take].to_vec();
214-
}
215-
}
214+
let rev: Vec<i64> = seq.iter().rev().copied().collect();
215+
let z = z_function(&rev);
216+
217+
// `best_end` is the EXCLUSIVE end of the earlier occurrence of the longest
218+
// recurring suffix; the draft is what followed it.
219+
let (mut best_len, mut best_end) = (0usize, 0usize);
220+
for (i, &zi) in z.iter().enumerate().skip(1) {
221+
// The occurrence must fit entirely before the position it ends at.
222+
let len = zi.min(n - i);
223+
if len > best_len {
224+
best_len = len;
225+
best_end = n - i;
226+
}
227+
}
228+
if best_len == 0 {
229+
return Vec::new();
230+
}
231+
let take = max_draft.min(n - best_end);
232+
if take == 0 {
233+
return Vec::new();
234+
}
235+
seq[best_end..best_end + take].to_vec()
236+
}
237+
238+
/// `z[i]` = length of the longest common prefix of `s` and `s[i..]` (`z[0] = n`).
239+
/// Linear time, the standard two-pointer construction.
240+
fn z_function(s: &[i64]) -> Vec<usize> {
241+
let n = s.len();
242+
let mut z = vec![0usize; n];
243+
if n == 0 {
244+
return z;
245+
}
246+
z[0] = n;
247+
let (mut l, mut r) = (0usize, 0usize);
248+
for i in 1..n {
249+
if i < r {
250+
z[i] = (r - i).min(z[i - l]);
251+
}
252+
while i + z[i] < n && s[z[i]] == s[i + z[i]] {
253+
z[i] += 1;
254+
}
255+
if i + z[i] > r {
256+
l = i;
257+
r = i + z[i];
216258
}
217259
}
218-
Vec::new()
260+
z
219261
}
220262

221263
#[cfg(test)]
@@ -224,20 +266,18 @@ mod tests {
224266

225267
#[test]
226268
fn empty_or_trivial_sequence_drafts_nothing() {
227-
assert!(prompt_lookup_draft(&[], 3, 4).is_empty());
228-
assert!(prompt_lookup_draft(&[7], 3, 4).is_empty());
229-
// A budget or ngram of zero disables drafting.
230-
assert!(prompt_lookup_draft(&[1, 2, 3], 0, 4).is_empty());
231-
assert!(prompt_lookup_draft(&[1, 2, 3], 3, 0).is_empty());
269+
assert!(prompt_lookup_draft(&[], 4).is_empty());
270+
assert!(prompt_lookup_draft(&[7], 4).is_empty());
271+
// A zero draft budget disables drafting.
272+
assert!(prompt_lookup_draft(&[1, 2, 3], 0).is_empty());
232273
}
233274

234275
#[test]
235276
fn repeated_suffix_drafts_its_earlier_continuation() {
236277
// "a b c d ... a b" → the suffix `a b` last continued with `c d e`.
237278
let seq = [10, 20, 30, 40, 99, 10, 20];
238-
let draft = prompt_lookup_draft(&seq, 3, 3);
239279
assert_eq!(
240-
draft,
280+
prompt_lookup_draft(&seq, 3),
241281
vec![30, 40, 99],
242282
"drafts what followed the earlier `10,20`"
243283
);
@@ -246,77 +286,64 @@ mod tests {
246286
#[test]
247287
fn longest_suffix_wins_over_a_shorter_ambiguous_one() {
248288
// The 2-gram `2 3` occurred once (→ 4); the 1-gram `3` also occurred
249-
// earlier (→ 4 as well). The longer, more specific match is used.
289+
// earlier. The longer, more specific match is used.
250290
let seq = [1, 2, 3, 4, 5, 2, 3];
251-
let draft = prompt_lookup_draft(&seq, 3, 2);
252-
assert_eq!(draft, vec![4, 5], "the 2-gram `2,3` context drafts `4,5`");
291+
assert_eq!(prompt_lookup_draft(&seq, 2), vec![4, 5]);
253292
}
254293

255294
#[test]
256295
fn most_recent_occurrence_is_preferred() {
257-
// `7` appears after position 0 (→ 1) and position 3 (→ 8). The most
258-
// recent earlier occurrence (index 3) drafts its follower.
296+
// `7` appears after position 0 (→ 1) and position 3 (→ 8). Among equally
297+
// long matches the freshest is used.
259298
let seq = [7, 1, 2, 7, 8, 9, 7];
260-
let draft = prompt_lookup_draft(&seq, 1, 1);
261-
assert_eq!(draft, vec![8], "the freshest match for `7` drafts `8`");
299+
assert_eq!(prompt_lookup_draft(&seq, 1), vec![8]);
262300
}
263301

264302
#[test]
265303
fn max_draft_caps_the_length() {
266304
let seq = [5, 6, 7, 8, 9, 5, 6];
267-
assert_eq!(prompt_lookup_draft(&seq, 3, 2), vec![7, 8]);
268-
assert_eq!(prompt_lookup_draft(&seq, 3, 10), vec![7, 8, 9, 5, 6]);
305+
assert_eq!(prompt_lookup_draft(&seq, 2), vec![7, 8]);
306+
assert_eq!(prompt_lookup_draft(&seq, 10), vec![7, 8, 9, 5, 6]);
269307
}
270308

271309
#[test]
272310
fn novel_suffix_drafts_nothing() {
273311
// The trailing `42` never occurred earlier → no draft, fall back to a step.
274312
let seq = [1, 2, 3, 4, 42];
275-
assert!(prompt_lookup_draft(&seq, 3, 4).is_empty());
313+
assert!(prompt_lookup_draft(&seq, 4).is_empty());
276314
}
277315

278-
// ── draft-pairing compatibility policy (row `speculative-draft-pairing`) ──
279-
280316
#[test]
281-
fn an_equal_or_larger_draft_is_compatible() {
282-
// Same-family self-pairing: identical vocab + context — the guaranteed
283-
// case the browser witness uses.
284-
assert!(draft_pairing_refusal(512, 128, 512, 128).is_none());
285-
// A draft that COVERS the target (larger vocab, longer context) is fine —
286-
// every target id indexes the draft and the sequence never overflows it.
287-
assert!(draft_pairing_refusal(512, 128, 1024, 4096).is_none());
317+
fn a_longer_context_beats_a_more_recent_shorter_one() {
318+
// The suffix `1,2,3` recurs (ending at 3) and continued with `7,8`.
319+
// The SHORTER suffix `2,3` has a MORE RECENT earlier occurrence (ending
320+
// at 7) that continued with `9`. Specificity wins: the longest recurring
321+
// context is the right one. A 2-token n-gram cap would have drafted `9`.
322+
let seq = [1, 2, 3, 7, 8, 2, 3, 9, 1, 2, 3];
323+
assert_eq!(prompt_lookup_draft(&seq, 2), vec![7, 8]);
288324
}
289325

290326
#[test]
291-
fn a_narrower_vocabulary_is_refused() {
292-
// A target id ≥ the draft's vocab would index the draft's embedding out
293-
// of range — refuse, naming both sizes.
294-
let reason = draft_pairing_refusal(512, 128, 400, 128).expect("refused");
295-
assert!(reason.contains("400") && reason.contains("512"), "{reason}");
296-
assert!(reason.contains("vocabulary"), "{reason}");
327+
fn the_drafter_has_no_context_cap_and_stays_linear() {
328+
// An arbitrarily long recurrence is matched in full — no n-gram ceiling.
329+
// 4096 tokens is far past any cap a tuning constant would have imposed,
330+
// and the O(n) search returns immediately.
331+
let period: Vec<i64> = (0..4096).collect();
332+
let mut seq = period.clone();
333+
seq.push(-1); // a separator so the recurrence is the whole period
334+
seq.extend_from_slice(&period);
335+
// The longest recurring suffix is the entire 4096-token period, whose
336+
// earlier occurrence ended just before the separator.
337+
assert_eq!(prompt_lookup_draft(&seq, 1), vec![-1]);
297338
}
298339

299340
#[test]
300-
fn a_shorter_context_is_refused() {
301-
// The target's realized sequence grows to 128; a 64-context draft would
302-
// abort its forward when it crossed 64 — refuse before it can.
303-
let reason = draft_pairing_refusal(512, 128, 512, 64).expect("refused");
304-
assert!(reason.contains("64") && reason.contains("128"), "{reason}");
305-
assert!(reason.contains("context"), "{reason}");
306-
}
307-
308-
#[test]
309-
fn an_unknown_target_vocabulary_is_refused() {
310-
// Vocab 0 = the config declared none; coverage cannot be verified, so
311-
// refuse rather than risk an out-of-range draft Gather.
312-
assert!(draft_pairing_refusal(0, 128, 512, 128).is_some());
313-
}
314-
315-
#[test]
316-
fn the_vocabulary_check_precedes_the_context_check() {
317-
// Both incompatible: the message names the vocabulary (the first gate),
318-
// deterministically — a stable refusal reason, never order-dependent.
319-
let reason = draft_pairing_refusal(512, 128, 400, 64).expect("refused");
320-
assert!(reason.contains("vocabulary"), "{reason}");
341+
fn z_function_matches_a_naive_reference() {
342+
let s: Vec<i64> = [1, 2, 1, 2, 1, 3, 1, 2, 1].to_vec();
343+
let z = z_function(&s);
344+
for i in 0..s.len() {
345+
let naive = (0..s.len() - i).take_while(|&k| s[k] == s[i + k]).count();
346+
assert_eq!(z[i], naive, "z[{i}]");
347+
}
321348
}
322349
}

features/suites/s3_execution/speculative_decode.feature

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ Feature: Speculative decode batches a drafted continuation without changing the
66
tokens and VERIFYING them in one M = K pass whose head emits logits at every
77
drafted position. The draft SOURCE is parametric — the verify/accept loop is
88
drafter-agnostic — so either a zero-weight PROMPT-LOOKUP (the tokens that
9-
followed the current suffix's most recent earlier occurrence) or a small
10-
DRAFT MODEL (a second decode session proposing the continuation from its own
11-
cheaper forward) plugs into the same loop, changing only the acceptance rate,
12-
never the output. Only the longest prefix the model would ITSELF
9+
followed the most recent earlier occurrence of the LONGEST recurring suffix of
10+
the realized sequence — no n-gram cap, found in O(n)) or a small DRAFT MODEL (a
11+
second decode session proposing the continuation from its own cheaper forward)
12+
plugs into the same loop, changing only the acceptance rate, never the output. Only the longest prefix the model would ITSELF
1313
produce under the SAMPLER is accepted; its K/V is spliced from that same pass
1414
and one correcting bonus token is committed. The accept rule is the caller's
1515
own next-token rule — greedy argmax, or a per-ABSOLUTE-position sample — the

0 commit comments

Comments
 (0)