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;