metrale_speculative/prompt_lookup.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-10-02: Prompt-lookup decoding: copy proposals from a sequence's own
4//! history (prompt and generated tokens). An index maps every n-gram of the
5//! history to the position just after its latest occurrence, so a proposal
6//! costs one hash lookup however long the history is. The proposal is the
7//! continuation of that occurrence, up to a window that doubles after a fully
8//! accepted copy and halves after a broken one. Host code only.
9//!
10//! Owner: speculative.
11//! Invariants:
12//! - A proposal is only ever a slice of the history it was asked about: the
13//! `n` tokens before its start equal the history's last `n` tokens, it is
14//! non-empty, and it never reaches past the history's end.
15//! - The index never holds the history's own final n-gram (it has no
16//! continuation yet), so a proposal never points at the suffix itself.
17//! - A stale entry (left by a rewind of the history) can never produce a
18//! proposal: every hit is re-checked token by token before it is used.
19//! - [`CopyWindow::current`] stays within `[min, max]`.
20
21use std::collections::HashMap;
22
23/// 2026-10-02: Per-sequence n-gram index over a token history.
24#[derive(Debug, Clone)]
25pub struct PromptLookupIndex {
26 /// 2026-10-02: n-gram length that must match before a copy is proposed.
27 n: usize,
28 /// 2026-10-02: n-gram key -> position of the token after its latest
29 /// indexed occurrence.
30 latest: HashMap<u64, u32>,
31 /// 2026-10-02: History length up to which n-grams are indexed: every
32 /// n-gram ending before `indexed_len - 1` is in `latest`.
33 indexed_len: usize,
34}
35
36/// 2026-10-02: Key of an n-gram. Collisions are harmless (every hit is
37/// re-checked against the history); the mix only keeps them rare.
38pub(crate) fn ngram_key(gram: &[u32]) -> u64 {
39 gram.iter().fold(0xcbf2_9ce4_8422_2325_u64, |h, &t| {
40 (h ^ u64::from(t))
41 .wrapping_mul(0x0000_0100_0000_01b3)
42 .rotate_left(29)
43 })
44}
45
46impl PromptLookupIndex {
47 /// 2026-10-02: An empty index matching n-grams of length `n` (`n >= 1`).
48 pub fn new(n: usize) -> Self {
49 assert!(n >= 1, "prompt-lookup n-gram length must be at least 1");
50 Self {
51 n,
52 latest: HashMap::new(),
53 indexed_len: 0,
54 }
55 }
56
57 /// 2026-10-02: The n-gram length this index matches.
58 pub fn ngram_len(&self) -> usize {
59 self.n
60 }
61
62 /// 2026-10-02: Indexes every n-gram of `history` that has a following
63 /// token and is not indexed yet. Amortised O(n) per new token. A history
64 /// shorter than the indexed length (a rewind) moves the mark back; the
65 /// entries past it are left in place and rejected on use.
66 pub fn observe(&mut self, history: &[u32]) {
67 if history.len() < self.indexed_len {
68 self.indexed_len = history.len();
69 }
70 // 2026-10-02: n-gram ending at e-1 (tokens [e-n, e)) is indexable when
71 // a token follows it, i.e. e < len.
72 let first_end = self.indexed_len.max(self.n);
73 for end in first_end..history.len() {
74 let key = ngram_key(&history[end - self.n..end]);
75 self.latest.insert(key, end as u32);
76 }
77 self.indexed_len = self.indexed_len.max(history.len());
78 }
79
80 /// 2026-10-02: The continuation of the latest earlier occurrence of
81 /// `history`'s final n-gram, at most `max_len` tokens; `None` when there
82 /// is no earlier occurrence, `max_len` is 0, or the history is too short.
83 /// Call [`observe`](Self::observe) with the same history first.
84 pub fn propose<'h>(&self, history: &'h [u32], max_len: usize) -> Option<&'h [u32]> {
85 let start = self.continuation_start(history)?;
86 if max_len == 0 {
87 return None;
88 }
89 let end = history.len().min(start.saturating_add(max_len));
90 Some(&history[start..end])
91 }
92
93 /// 2026-10-02: Where the continuation [`propose`](Self::propose) copies
94 /// from starts: the position after the latest earlier occurrence of the
95 /// history's final n-gram, re-checked token by token.
96 pub fn continuation_start(&self, history: &[u32]) -> Option<usize> {
97 let len = history.len();
98 if len <= self.n {
99 return None;
100 }
101 let suffix = &history[len - self.n..];
102 let start = *self.latest.get(&ngram_key(suffix))? as usize;
103 if start < self.n || start >= len || history[start - self.n..start] != *suffix {
104 return None;
105 }
106 Some(start)
107 }
108
109 /// 2026-10-02: How many tokens before `start` equal the tokens before the
110 /// history's end, counted back from both, up to `cap`: the length of the
111 /// match a copy starting at `start` rests on. At least `n` for a start
112 /// [`propose`](Self::propose) returned.
113 pub fn match_len(history: &[u32], start: usize, cap: usize) -> usize {
114 let len = history.len();
115 (1..=cap.min(start))
116 .take_while(|&k| history[start - k] == history[len - k])
117 .count()
118 }
119}
120
121/// 2026-10-02: Copy window: how many tokens a copy may propose. It doubles
122/// after a copy is accepted in full and halves after one is cut short.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub struct CopyWindow {
125 current: usize,
126 min: usize,
127 max: usize,
128}
129
130impl CopyWindow {
131 /// 2026-10-02: A window starting at `start`, kept within `[min, max]`
132 /// (`1 <= min <= max`).
133 pub fn new(start: usize, min: usize, max: usize) -> Self {
134 assert!(
135 min >= 1 && min <= max,
136 "copy window bounds must satisfy 1 <= min <= max"
137 );
138 Self {
139 current: start.clamp(min, max),
140 min,
141 max,
142 }
143 }
144
145 /// 2026-10-02: Tokens the next copy may propose.
146 pub fn current(&self) -> usize {
147 self.current
148 }
149
150 /// 2026-10-02: Updates the window after a copy of `proposed` tokens of
151 /// which the first `accepted` matched the target.
152 pub fn record(&mut self, proposed: usize, accepted: usize) {
153 if proposed == 0 {
154 return;
155 }
156 self.current = if accepted >= proposed {
157 self.current.saturating_mul(2).min(self.max)
158 } else {
159 (self.current / 2).max(self.min)
160 };
161 }
162}
163
164/// 2026-10-02: Where a sequence's copy window starts (capped at
165/// `--prompt-lookup-max-drafts`). A short first copy bounds the verify rows an
166/// untested match can waste; a fully accepted copy doubles the window.
167pub const WINDOW_START: usize = 2;
168
169/// 2026-10-02: Rounds to skip after `misses` copies in a row that matched
170/// nothing: `2^(misses-1)`, capped at `cap` (0 for no backoff).
171pub fn backoff_rounds(misses: u32, cap: usize) -> usize {
172 if cap == 0 || misses == 0 {
173 return 0;
174 }
175 1usize
176 .checked_shl(misses - 1)
177 .unwrap_or(usize::MAX)
178 .min(cap)
179}
180
181/// 2026-10-02: Serve-wide prompt-lookup settings (`--prompt-lookup-*`).
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub struct PromptLookupConfig {
184 /// 2026-10-02: n-gram length that must match (`--prompt-lookup-ngram`).
185 pub ngram: usize,
186 /// 2026-10-02: Most tokens one copy proposes (`--prompt-lookup-max-drafts`);
187 /// the copy window's ceiling.
188 pub max_drafts: usize,
189 /// 2026-10-02: Widest batch that proposes copies (`--prompt-lookup-max-seqs`).
190 pub max_seqs: usize,
191 /// 2026-10-02: Tokens a copy's match must span, counted back from the
192 /// history's end (`--prompt-lookup-min-match`, at least `ngram`).
193 pub min_match: usize,
194 /// 2026-10-02: Most rounds a sequence skips copying after copies that
195 /// matched nothing (`--prompt-lookup-miss-backoff`); 0 never skips.
196 pub miss_backoff: usize,
197}
198
199/// 2026-10-02: One sequence's prompt-lookup state: its index, its copy window,
200/// and the length of the copy now awaiting verification (0 when the pending
201/// drafts are the model drafter's).
202#[derive(Debug, Clone)]
203pub struct PromptLookupSeq {
204 index: PromptLookupIndex,
205 window: CopyWindow,
206 in_flight: usize,
207 min_match: usize,
208 miss_backoff: usize,
209 /// 2026-10-02: Copies in a row that matched nothing.
210 misses: u32,
211 /// 2026-10-02: Rounds left to skip copying.
212 cooldown: usize,
213 /// 2026-10-04: The in-flight copy came from the cross-request cache
214 /// ([`crate::shared_lookup`]), not this sequence's own history.
215 in_flight_shared: bool,
216}
217
218impl PromptLookupSeq {
219 /// 2026-10-02: Fresh state. The window starts at [`WINDOW_START`] (or the
220 /// ceiling, if lower), so an untested sequence's first copies are short and
221 /// a wrong one costs few verify rows.
222 pub fn new(cfg: &PromptLookupConfig) -> Self {
223 Self {
224 index: PromptLookupIndex::new(cfg.ngram),
225 window: CopyWindow::new(WINDOW_START, 1, cfg.max_drafts),
226 in_flight: 0,
227 min_match: cfg.min_match.max(cfg.ngram),
228 miss_backoff: cfg.miss_backoff,
229 misses: 0,
230 cooldown: 0,
231 in_flight_shared: false,
232 }
233 }
234
235 /// 2026-10-02: Proposes a copy of at most `max_len` tokens for `history`
236 /// (prompt, generated tokens and the token about to be verified), and marks
237 /// it in flight. `None` (and nothing in flight) when there is no match or
238 /// `max_len` is 0. The caller bounds `max_len`, normally by
239 /// [`window`](Self::window).
240 ///
241 /// No copy is proposed while a miss backoff is cooling down (each call
242 /// counts one round), or when the match spans fewer than `min_match`
243 /// tokens.
244 pub fn propose(&mut self, history: &[u32], max_len: usize) -> Option<Vec<u32>> {
245 self.propose_with(history, max_len, |_, _| None)
246 }
247
248 /// 2026-10-04: [`propose`](Self::propose), but when the sequence's own
249 /// history has no match, asks `shared` (the cross-request cache) for a
250 /// copy of at most `max_len` tokens. The own history is asked first; the
251 /// cooldown applies to both; a shared copy is settled like an own one.
252 pub fn propose_with(
253 &mut self,
254 history: &[u32],
255 max_len: usize,
256 shared: impl FnOnce(&[u32], usize) -> Option<Vec<u32>>,
257 ) -> Option<Vec<u32>> {
258 self.in_flight = 0;
259 self.in_flight_shared = false;
260 self.index.observe(history);
261 if self.cooldown > 0 {
262 self.cooldown -= 1;
263 return None;
264 }
265 if max_len == 0 {
266 return None;
267 }
268 let own = self.index.continuation_start(history).filter(|&start| {
269 PromptLookupIndex::match_len(history, start, self.min_match) >= self.min_match
270 });
271 let copy = match own {
272 Some(start) => history[start..history.len().min(start + max_len)].to_vec(),
273 None => {
274 let copy = shared(history, max_len).filter(|c| !c.is_empty())?;
275 self.in_flight_shared = true;
276 copy[..copy.len().min(max_len)].to_vec()
277 }
278 };
279 self.in_flight = copy.len();
280 Some(copy)
281 }
282
283 /// 2026-10-04: The in-flight copy came from the cross-request cache.
284 pub fn in_flight_shared(&self) -> bool {
285 self.in_flight > 0 && self.in_flight_shared
286 }
287
288 /// 2026-10-02: Length of the copy awaiting verification; 0 when none.
289 pub fn in_flight(&self) -> usize {
290 self.in_flight
291 }
292
293 /// 2026-10-02: Closes the in-flight copy: `accepted` of its tokens matched.
294 /// Returns the copy's length (0 when none was in flight, which changes
295 /// nothing).
296 ///
297 /// A copy that matched nothing starts (or doubles) a backoff of up to
298 /// `miss_backoff` rounds; any accepted token clears it.
299 pub fn settle(&mut self, accepted: usize) -> usize {
300 let proposed = std::mem::take(&mut self.in_flight);
301 if proposed == 0 {
302 return 0;
303 }
304 self.window.record(proposed, accepted.min(proposed));
305 if accepted == 0 {
306 self.misses = self.misses.saturating_add(1);
307 self.cooldown = backoff_rounds(self.misses, self.miss_backoff);
308 } else {
309 self.misses = 0;
310 }
311 proposed
312 }
313
314 /// 2026-10-02: Drops an in-flight copy without judging it (its drafts were
315 /// discarded unverified).
316 pub fn abandon(&mut self) {
317 self.in_flight = 0;
318 }
319
320 /// 2026-10-02: The current copy window.
321 pub fn window(&self) -> usize {
322 self.window.current()
323 }
324}
325
326#[cfg(test)]
327#[path = "prompt_lookup_tests.rs"]
328mod tests;