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}