Skip to main content

qualia_core_db/inference/
prompt_lookup.rs

1//! W6a — prompt-lookup (n-gram) speculative decoding: the proposer.
2//!
3//! Standard "prompt lookup decoding" (a.k.a. LLMA): instead of a separate draft model, draft the
4//! next few tokens by finding where the current context suffix already occurred earlier in the same
5//! context (prompt + generated), and proposing the tokens that FOLLOWED that earlier occurrence.
6//! Those drafts are then VERIFIED by one batched forward and the longest agreeing prefix is kept —
7//! so the emitted text is **bit-identical to greedy decode** (the verify step never accepts a token
8//! the model would not have produced greedily). The win is pure latency on repetitive / quoting /
9//! structured text (code, JSON, lists, cited passages); on non-repetitive text it proposes little
10//! and costs ~nothing. This module is the PROPOSER only — pure, allocation-light, unit-tested, no
11//! GPU. The verify/accept wiring lives in the decode loop.
12
13/// Longest n-gram suffix length to try when looking for a recurrence.
14pub const MAX_NGRAM: usize = 3;
15/// Hard cap on draft length (also bounded by the caller's scratch/batch width).
16pub const MAX_DRAFT: usize = 8;
17
18/// A drafted continuation: `tokens[..len]` are the proposed next-token ids.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct Draft {
21    pub tokens: [u32; MAX_DRAFT],
22    pub len: usize,
23}
24
25impl Draft {
26    #[inline]
27    pub const fn empty() -> Self {
28        Self {
29            tokens: [0; MAX_DRAFT],
30            len: 0,
31        }
32    }
33    #[inline]
34    pub fn as_slice(&self) -> &[u32] {
35        &self.tokens[..self.len]
36    }
37}
38
39/// Propose up to `max_draft` tokens by matching the longest available suffix of `ctx` against an
40/// earlier occurrence in `ctx`, and returning the tokens that followed that occurrence.
41///
42/// - Tries suffix lengths `MAX_NGRAM..=1` (longest match first — higher-order n-grams are more
43///   selective, so their continuation is likelier to be accepted).
44/// - For a given n-gram, uses the MOST RECENT earlier occurrence (locality: recent context is the
45///   best predictor of the immediate continuation).
46/// - Never proposes past the end of `ctx` (only real, already-seen tokens are drafted).
47/// - Returns [`Draft::empty`] when nothing recurs (the common case for novel text).
48pub fn propose(ctx: &[u32], max_draft: usize) -> Draft {
49    let k = max_draft.min(MAX_DRAFT);
50    if k == 0 || ctx.len() < 2 {
51        return Draft::empty();
52    }
53    let n = ctx.len();
54    // Try the longest suffix first. Suffix length `g` must leave room for an earlier occurrence
55    // plus at least one following token: earliest match start `i` satisfies `i + g < n` (so
56    // `ctx[i+g]` exists) and `i < n - g` (the match is strictly before the current suffix).
57    let max_g = MAX_NGRAM.min(n - 1);
58    for g in (1..=max_g).rev() {
59        let suffix = &ctx[n - g..];
60        // Search earlier windows [0, n-g) for the LAST occurrence of `suffix`, scanning right→left
61        // so the first hit is the most recent. The match must start at `i` with `i + g <= n - 1`
62        // (i.e. `i <= n - g - 1`) so that at least one continuation token `ctx[i+g]` exists.
63        if n < g + 1 {
64            continue;
65        }
66        let latest_start = n - g - 1; // inclusive upper bound on match start
67        let mut i = latest_start as isize;
68        while i >= 0 {
69            let ii = i as usize;
70            if ctx[ii..ii + g] == *suffix {
71                let mut d = Draft::empty();
72                let src = ii + g; // first continuation token position
73                let avail = n - src; // tokens available after the match
74                let take = avail.min(k);
75                d.tokens[..take].copy_from_slice(&ctx[src..src + take]);
76                d.len = take;
77                if d.len > 0 {
78                    return d;
79                }
80            }
81            i -= 1;
82        }
83    }
84    Draft::empty()
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn empty_on_short_or_novel() {
93        assert_eq!(propose(&[], 4).len, 0);
94        assert_eq!(propose(&[7], 4).len, 0);
95        // strictly increasing, no recurrence
96        assert_eq!(propose(&[1, 2, 3, 4, 5], 4).len, 0);
97    }
98
99    #[test]
100    fn proposes_bigram_continuation() {
101        // "1 2 3 9 | 1 2" → suffix [1,2] recurs at pos 0; the drafter proposes the WHOLE earlier
102        // continuation [3,9,1,2] (the verify step later trims to the model-agreeing prefix — drafting
103        // several ahead is the point).
104        let ctx = [1u32, 2, 3, 9, 1, 2];
105        let d = propose(&ctx, 4);
106        assert_eq!(
107            d.as_slice(),
108            &[3, 9, 1, 2],
109            "drafts the earlier continuation of [1,2]"
110        );
111    }
112
113    #[test]
114    fn prefers_longest_ngram() {
115        // The trigram [7,1,2] and the bigram [1,2] recur with DIFFERENT continuations: after the
116        // earlier [7,1,2] came 9; after the more-recent bare [1,2] came 7. The trigram is more
117        // selective and must win → draft starts with 9, not 7.
118        let ctx = [7u32, 1, 2, 9, 3, 1, 2, 7, 1, 2];
119        let d = propose(&ctx, 4);
120        assert_eq!(
121            d.as_slice()[0],
122            9,
123            "trigram continuation (9), not the bigram's (7)"
124        );
125    }
126
127    #[test]
128    fn uses_most_recent_occurrence() {
129        // Bare [5] recurs at idx 0 (→ next is 1) and idx 2 (→ next is 2). The MOST RECENT earlier
130        // occurrence (idx 2) governs → draft starts with 2, not 1.
131        let ctx = [5u32, 1, 5, 2, 5];
132        let d = propose(&ctx, 3);
133        assert_eq!(d.as_slice()[0], 2, "most-recent [5] is followed by 2");
134    }
135
136    #[test]
137    fn respects_max_draft() {
138        // long repeat so many continuation tokens are available; cap at max_draft.
139        let ctx = [1u32, 2, 3, 4, 5, 6, 7, 1, 2];
140        let d = propose(&ctx, 3);
141        assert_eq!(d.as_slice(), &[3, 4, 5], "capped at max_draft=3");
142        let d2 = propose(&ctx, MAX_DRAFT + 100);
143        assert!(d2.len <= MAX_DRAFT, "never exceeds MAX_DRAFT");
144    }
145
146    #[test]
147    fn no_continuation_when_match_is_the_tail() {
148        // The only earlier occurrence has no following token — must not propose (no OOB read).
149        // suffix [2] ; earlier [2] only at idx that is immediately before the suffix → still has a
150        // continuation here, so construct a true no-continuation: [2, 5, 2] suffix [2] earlier at 0
151        // → continuation [5]; to get none, the match must be at n-g-... covered by bounds. Verify a
152        // degenerate all-same case stays in-bounds and terminates.
153        let ctx = [2u32, 2, 2, 2];
154        let d = propose(&ctx, 4);
155        // suffix [2,2,2] (g=3) earlier occurrence start 0? ctx[0..3]=[2,2,2]==suffix ctx[1..4] → yes,
156        // continuation at src=3 → [2]. Deterministic + in-bounds.
157        assert_eq!(d.as_slice(), &[2]);
158    }
159
160    #[test]
161    fn draft_is_a_prefix_of_real_tokens() {
162        // Whatever is drafted must be tokens that literally appear in ctx (never fabricated) —
163        // the exact-output safety premise. Fuzz a few structured inputs.
164        let inputs: [&[u32]; 3] = [
165            &[1, 2, 1, 2, 1, 2],
166            &[4, 4, 5, 4, 4],
167            &[7, 8, 9, 7, 8, 9, 7],
168        ];
169        for ctx in inputs {
170            let d = propose(ctx, MAX_DRAFT);
171            for &t in d.as_slice() {
172                assert!(
173                    ctx.contains(&t),
174                    "drafted token {t} not present in ctx {ctx:?}"
175                );
176            }
177        }
178    }
179}