Skip to main content

inferencelayer/
sampling.rs

1//! Wasm-safe CPU sampling: penalties, logit bias, truncation (top-k / top-p / min-p), and
2//! per-token logprobs — pure functions over a logits slice with no GPU and no server-feature
3//! dependency, so the browser/wasm build and the native server share exactly this code and every
4//! rule is unit-tested on synthetic logits.
5//!
6//! **Application order (fixed, mirrors vLLM):** penalties → logit bias → temperature → truncation
7//! (top-k → top-p → min-p) → draw. Penalties themselves apply repetition (multiplicative, over
8//! prompt ∪ output) before frequency and presence (subtractive, over output), matching HF/vLLM.
9//!
10//! Logprobs, when requested, are the `log_softmax` of the RAW read-back column — the model's own
11//! probabilities — independent of the penalty/truncation used to pick the token, which is what the
12//! OpenAI `logprobs` field reports.
13//!
14//! Determinism: the draw uses the same `xorshift64` stream keyed only on the request seed as the
15//! original greedy-or-nucleus sampler, and at default knobs (`top_k = None`, `min_p = None`) the
16//! truncation + draw is bit-identical to it — so the seeded-replay serving contract is preserved.
17
18use std::collections::{HashMap, HashSet};
19
20/// Per-request xorshift64 RNG advance. The stream is a pure function of the seed, so a sampled
21/// request replays identically regardless of what traffic it shared batches with.
22pub fn xorshift64(state: &mut u64) -> u64 {
23    let mut x = *state;
24    x ^= x << 13;
25    x ^= x >> 7;
26    x ^= x << 17;
27    *state = x;
28    x
29}
30
31/// Apply presence / frequency / repetition penalties in place.
32///
33/// - **repetition** (`repetition_penalty`, HF/vLLM multiplicative, over `prompt ∪ emitted`, once
34///   per unique id): `l ← l/r` where `l > 0`, else `l·r`. `r = 1` is a no-op.
35/// - **frequency** (`frequency_penalty`, over `emitted`): `l ← l − f·count(t)`.
36/// - **presence** (`presence_penalty`, over `emitted`, once per unique id): `l ← l − p`.
37pub fn apply_penalties(
38    logits: &mut [f32],
39    prompt: &[u32],
40    emitted: &[u32],
41    presence: f32,
42    frequency: f32,
43    repetition: f32,
44) {
45    if repetition != 1.0 {
46        let mut seen = HashSet::new();
47        for &t in prompt.iter().chain(emitted) {
48            if seen.insert(t)
49                && let Some(l) = logits.get_mut(t as usize)
50            {
51                *l = if *l > 0.0 {
52                    *l / repetition
53                } else {
54                    *l * repetition
55                };
56            }
57        }
58    }
59    if frequency != 0.0 || presence != 0.0 {
60        let mut counts: HashMap<u32, u32> = HashMap::new();
61        for &t in emitted {
62            *counts.entry(t).or_default() += 1;
63        }
64        for (&t, &c) in &counts {
65            if let Some(l) = logits.get_mut(t as usize) {
66                *l -= frequency * c as f32;
67                *l -= presence;
68            }
69        }
70    }
71}
72
73/// Add per-token logit bias in place, clamped to ±100 (OpenAI's range; −100 effectively bans a
74/// token, +100 all but forces it).
75pub fn apply_logit_bias(logits: &mut [f32], bias: &[(u32, f32)]) {
76    for &(id, b) in bias {
77        if let Some(l) = logits.get_mut(id as usize) {
78            *l += b.clamp(-100.0, 100.0);
79        }
80    }
81}
82
83/// The lowest-id argmax of `logits` (greedy pick after penalties/bias, when `temperature == 0`).
84pub fn argmax(logits: &[f32]) -> u32 {
85    let mut best = 0u32;
86    let mut best_v = f32::NEG_INFINITY;
87    for (i, &l) in logits.iter().enumerate() {
88        if l > best_v {
89            best_v = l;
90            best = i as u32;
91        }
92    }
93    best
94}
95
96/// Temperature + truncation (top-k → top-p → min-p) nucleus draw with the deterministic
97/// per-request RNG. At `top_k = None, min_p = None` this reduces exactly to plain nucleus sampling.
98///
99/// # Why this selects instead of sorting
100///
101/// This used to build a `Vec<(u32, f32)>` over the WHOLE vocabulary and sort it — then keep at most
102/// `top_k` entries. On this engine's 4B (vocab 248,320) that is a ~4.5M-comparison sort, per token,
103/// to choose 20 candidates. It measured **5–6 ms per token** in the decode loop, against ~24 ms of
104/// GPU work: roughly a fifth of decode, spent ordering 248,300 tokens that were about to be thrown
105/// away.
106///
107/// Everything downstream — top-p, min-p, the draw — only ever reads a PREFIX of the sorted order and
108/// only ever SHRINKS it. So the k largest, sorted among themselves, is all that is ever needed. That
109/// is a bounded selection (one pass, a k-sized heap), not a sort.
110///
111/// `top_k = None` (nucleus with no k) has no static bound, so the candidate set grows geometrically
112/// until it holds `top_p` of the mass — in practice a handful of iterations over a small k, and it
113/// degrades to the full vocabulary only if the distribution is near-uniform, which is the one case
114/// where a sort was never avoidable anyway.
115///
116/// Tie-breaking matches the old `sort_by` (which is STABLE): equal probabilities keep ascending
117/// token order. `sampler_matches_the_sorting_reference` pins that against the original implementation.
118pub fn sample(
119    logits: &[f32],
120    temperature: f32,
121    top_k: Option<usize>,
122    top_p: f32,
123    min_p: Option<f32>,
124    rng: &mut u64,
125) -> u32 {
126    let n = logits.len();
127    if n == 0 {
128        return 0;
129    }
130    let inv_t = 1.0 / temperature.max(1e-6);
131    let mx = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
132
133    // `z` is over the WHOLE vocabulary — the mass top_p is expressed against — so this pass cannot
134    // be avoided. The sort could.
135    let mut z = 0.0f32;
136    for &l in logits {
137        z += ((l - mx) * inv_t).exp();
138    }
139    let inv_z = if z > 0.0 { 1.0 / z } else { 0.0 };
140
141    // Candidate budget. With an explicit top_k it is exact; without one, grow until the nucleus is
142    // covered (top_p is a mass threshold, so a prefix holding >= top_p can never need more).
143    let hard_k = top_k.map(|k| k.max(1).min(n));
144    let mut cap = hard_k.unwrap_or(64.min(n));
145    let probs = loop {
146        let probs = top_candidates(logits, cap, mx, inv_t, inv_z);
147        let covered: f32 = probs.iter().map(|(_, p)| *p).sum();
148        if hard_k.is_some() || cap >= n || covered >= top_p {
149            break probs;
150        }
151        cap = (cap * 4).min(n);
152    };
153
154    // ── from here down this is the original logic, verbatim, over the candidate prefix ──
155    let mut end = probs.len();
156    // top-p: smallest prefix with cumulative mass ≥ top_p (always ≥ 1 token).
157    let mut mass = 0.0f32;
158    let mut nucleus_end = 0;
159    for p in probs.iter().take(end) {
160        mass += p.1;
161        nucleus_end += 1;
162        if mass >= top_p {
163            break;
164        }
165    }
166    end = nucleus_end;
167    // min-p: drop tokens below `min_p × max_prob` (max is probs[0]). Keep the top token always,
168    // then extend through the sorted run still at/above the threshold.
169    if let Some(mp) = min_p {
170        let thresh = mp * probs[0].1;
171        let kept = probs[1..end]
172            .iter()
173            .take_while(|(_, p)| *p >= thresh)
174            .count();
175        end = 1 + kept;
176    }
177
178    let kept_mass: f32 = probs.iter().take(end).map(|(_, p)| p).sum();
179    let draw = (xorshift64(rng) >> 11) as f32 / (1u64 << 53) as f32 * kept_mass;
180    let mut acc = 0.0f32;
181    for (id, p) in probs.iter().take(end) {
182        acc += p;
183        if draw <= acc {
184            return *id;
185        }
186    }
187    probs[end - 1].0
188}
189
190/// The `k` highest-probability tokens, highest first, ties broken by ascending token id.
191///
192/// One pass keeping a k-sized ordered set: O(n) probability evaluations plus O(k) work only on the
193/// rare element that beats the current worst candidate. Versus O(n log n) and a vocabulary-sized
194/// allocation for the sort this replaces.
195fn top_candidates(logits: &[f32], k: usize, mx: f32, inv_t: f32, inv_z: f32) -> Vec<(u32, f32)> {
196    // `a` is a WORSE candidate than `b` when it has a lower probability — or the same probability
197    // and a higher token id, because the stable sort this replaces kept the lower id first.
198    fn worse_than(a: &(u32, f32), b: &(u32, f32)) -> bool {
199        match a.1.total_cmp(&b.1) {
200            std::cmp::Ordering::Less => true,
201            std::cmp::Ordering::Greater => false,
202            std::cmp::Ordering::Equal => a.0 > b.0,
203        }
204    }
205
206    // Kept sorted WORST-FIRST, so `best[0]` is the eviction candidate and the guard below is one
207    // comparison per vocabulary entry.
208    let mut best: Vec<(u32, f32)> = Vec::with_capacity(k + 1);
209    for (i, &l) in logits.iter().enumerate() {
210        let cand = (i as u32, ((l - mx) * inv_t).exp() * inv_z);
211        if best.len() == k && !worse_than(&best[0], &cand) {
212            continue; // cannot displace even the worst kept candidate
213        }
214        let pos = best.partition_point(|x| worse_than(x, &cand));
215        best.insert(pos, cand);
216        if best.len() > k {
217            best.remove(0);
218        }
219    }
220    best.reverse(); // best first — the order the old stable sort produced
221    best
222}
223
224/// The chosen token's logprob plus the `top_n` highest-logprob alternatives, from `log_softmax` of
225/// the RAW logits column.
226#[derive(Clone, Debug, PartialEq)]
227pub struct TokenLogprobs {
228    pub chosen: u32,
229    pub chosen_logprob: f32,
230    /// `(token_id, logprob)`, highest logprob first, length ≤ `top_n`.
231    pub top: Vec<(u32, f32)>,
232}
233
234/// `log_softmax(logits)` evaluated at `chosen` and at the `top_n` highest-logprob tokens.
235pub fn log_softmax_at(logits: &[f32], chosen: u32, top_n: usize) -> TokenLogprobs {
236    let mx = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
237    let sumexp: f32 = logits.iter().map(|&l| (l - mx).exp()).sum();
238    let logz = mx + sumexp.ln();
239    let chosen_logprob = logits
240        .get(chosen as usize)
241        .map(|&l| l - logz)
242        .unwrap_or(f32::NEG_INFINITY);
243    // Bounded selection of the top_n by logit (= by logprob); ties resolve to the lower id.
244    let mut top: Vec<(u32, f32)> = Vec::with_capacity(top_n);
245    let sort_desc = |t: &mut Vec<(u32, f32)>| {
246        t.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal))
247    };
248    for (i, &l) in logits.iter().enumerate() {
249        let lp = l - logz;
250        if top.len() < top_n {
251            top.push((i as u32, lp));
252            if top.len() == top_n {
253                sort_desc(&mut top);
254            }
255        } else if top_n > 0 && lp > top[top_n - 1].1 {
256            top[top_n - 1] = (i as u32, lp);
257            sort_desc(&mut top);
258        }
259    }
260    if top.len() < top_n {
261        sort_desc(&mut top);
262    }
263    TokenLogprobs {
264        chosen,
265        chosen_logprob,
266        top,
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn presence_penalty_subtracts_once_per_emitted_token() {
276        let mut l = vec![1.0, 2.0, 3.0, 4.0];
277        // token 1 emitted twice, token 3 once; presence subtracts p once regardless of count.
278        apply_penalties(&mut l, &[], &[1, 1, 3], 0.5, 0.0, 1.0);
279        assert_eq!(l, vec![1.0, 1.5, 3.0, 3.5]);
280    }
281
282    #[test]
283    fn frequency_penalty_scales_with_count() {
284        let mut l = vec![0.0, 10.0, 10.0];
285        // token 1 appears 3×, token 2 once; freq subtracts f·count.
286        apply_penalties(&mut l, &[], &[1, 1, 1, 2], 0.0, 2.0, 1.0);
287        assert_eq!(l, vec![0.0, 4.0, 8.0]);
288    }
289
290    #[test]
291    fn repetition_penalty_is_multiplicative_over_prompt_and_output_once() {
292        let mut l = vec![2.0, -2.0, 5.0];
293        // r=2: positive logits halved, negative doubled; prompt token 0, emitted token 1.
294        apply_penalties(&mut l, &[0], &[1], 0.0, 0.0, 2.0);
295        assert_eq!(l, vec![1.0, -4.0, 5.0]);
296        // A token repeated across prompt and emitted is penalized ONCE (not compounded).
297        let mut l2 = vec![8.0];
298        apply_penalties(&mut l2, &[0, 0], &[0, 0], 0.0, 0.0, 2.0);
299        assert_eq!(l2, vec![4.0]);
300    }
301
302    #[test]
303    fn logit_bias_clamps_to_plus_minus_hundred() {
304        let mut l = vec![0.0, 0.0, 0.0];
305        apply_logit_bias(&mut l, &[(0, 1000.0), (1, -1000.0), (2, 5.0)]);
306        assert_eq!(l, vec![100.0, -100.0, 5.0]);
307    }
308
309    #[test]
310    fn logit_bias_minus_hundred_bans_a_token_from_greedy() {
311        // token 2 is the argmax until a −100 bias buries it.
312        let mut l = vec![1.0, 2.0, 3.0];
313        apply_logit_bias(&mut l, &[(2, -100.0)]);
314        assert_eq!(argmax(&l), 1);
315    }
316
317    #[test]
318    fn top_k_restricts_the_candidate_set() {
319        // A near-flat distribution; top_k=1 forces the argmax deterministically for any rng.
320        let l = vec![3.0, 2.9, 2.8, 2.7];
321        for seed in 0..8u64 {
322            let mut rng = seed ^ 0x9e37_79b9_7f4a_7c15;
323            assert_eq!(sample(&l, 1.0, Some(1), 1.0, None, &mut rng), 0);
324        }
325    }
326
327    #[test]
328    fn min_p_drops_low_probability_tail() {
329        // Token 0 dominates; min_p=0.5 keeps only tokens with prob ≥ 0.5·max ⇒ just token 0.
330        let l = vec![10.0, 0.0, 0.0, 0.0];
331        for seed in 0..8u64 {
332            let mut rng = seed ^ 0x1234;
333            assert_eq!(sample(&l, 1.0, None, 1.0, Some(0.5), &mut rng), 0);
334        }
335    }
336
337    #[test]
338    fn sample_defaults_reduce_to_plain_nucleus_and_are_seed_reproducible() {
339        // The same seed always yields the same draw (the serving replay contract).
340        let l = vec![1.0, 2.0, 1.5, 0.5, 3.0];
341        let mut a = 42u64 ^ 0x9e37_79b9_7f4a_7c15;
342        let mut b = 42u64 ^ 0x9e37_79b9_7f4a_7c15;
343        let ta: Vec<u32> = (0..5)
344            .map(|_| sample(&l, 0.8, None, 0.9, None, &mut a))
345            .collect();
346        let tb: Vec<u32> = (0..5)
347            .map(|_| sample(&l, 0.8, None, 0.9, None, &mut b))
348            .collect();
349        assert_eq!(ta, tb);
350    }
351
352    #[test]
353    fn logprobs_are_a_normalized_log_softmax() {
354        let l = vec![0.0, 0.0]; // uniform ⇒ each logprob = ln(0.5)
355        let lp = log_softmax_at(&l, 0, 2);
356        assert!((lp.chosen_logprob - 0.5f32.ln()).abs() < 1e-5);
357        // Probabilities from the logprobs sum to 1.
358        let mass: f32 = lp.top.iter().map(|(_, x)| x.exp()).sum();
359        assert!((mass - 1.0).abs() < 1e-5);
360    }
361
362    #[test]
363    fn chosen_logprob_matches_its_entry_in_top() {
364        // The gate: the chosen token's standalone logprob equals its value inside `top`.
365        let l = vec![1.0, 3.0, 2.0, 0.5];
366        let chosen = 1; // the argmax
367        let lp = log_softmax_at(&l, chosen, 3);
368        let in_top = lp
369            .top
370            .iter()
371            .find(|(id, _)| *id == chosen)
372            .expect("chosen in top");
373        assert_eq!(in_top.1, lp.chosen_logprob);
374        // `top` is sorted highest-logprob first.
375        assert_eq!(lp.top[0].0, 1);
376    }
377}
378
379#[cfg(test)]
380mod selection {
381    use super::*;
382
383    /// The ORIGINAL sort-based sampler, kept verbatim as the oracle.
384    ///
385    /// `sample` replaced a full sort of the vocabulary with a bounded selection. That is only an
386    /// optimization if it draws the SAME token — otherwise it is a silent behaviour change in the
387    /// one place a model's output is actually decided, and it would surface as "the model got
388    /// worse", not as a bug.
389    fn sample_by_sorting(
390        logits: &[f32],
391        temperature: f32,
392        top_k: Option<usize>,
393        top_p: f32,
394        min_p: Option<f32>,
395        rng: &mut u64,
396    ) -> u32 {
397        let inv_t = 1.0 / temperature.max(1e-6);
398        let mx = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
399        let mut probs: Vec<(u32, f32)> = logits
400            .iter()
401            .enumerate()
402            .map(|(i, &l)| (i as u32, ((l - mx) * inv_t).exp()))
403            .collect();
404        let z: f32 = probs.iter().map(|(_, p)| p).sum();
405        for p in probs.iter_mut() {
406            p.1 /= z;
407        }
408        probs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
409
410        let mut end = probs.len();
411        if let Some(k) = top_k {
412            end = end.min(k.max(1));
413        }
414        let mut mass = 0.0f32;
415        let mut nucleus_end = 0;
416        for p in probs.iter().take(end) {
417            mass += p.1;
418            nucleus_end += 1;
419            if mass >= top_p {
420                break;
421            }
422        }
423        end = nucleus_end;
424        if let Some(mp) = min_p {
425            let thresh = mp * probs[0].1;
426            let kept = probs[1..end]
427                .iter()
428                .take_while(|(_, p)| *p >= thresh)
429                .count();
430            end = 1 + kept;
431        }
432        let kept_mass: f32 = probs.iter().take(end).map(|(_, p)| p).sum();
433        let draw = (xorshift64(rng) >> 11) as f32 / (1u64 << 53) as f32 * kept_mass;
434        let mut acc = 0.0f32;
435        for (id, p) in probs.iter().take(end) {
436            acc += p;
437            if draw <= acc {
438                return *id;
439            }
440        }
441        probs[end - 1].0
442    }
443
444    fn logits(n: usize, seed: u64, flat: bool) -> Vec<f32> {
445        let mut r = seed;
446        (0..n)
447            .map(|_| {
448                let v = (xorshift64(&mut r) >> 40) as f32 / 1024.0;
449                if flat { v * 0.001 } else { v } // `flat` ⇒ near-uniform: forces the no-top_k grow path to widen
450            })
451            .collect()
452    }
453
454    #[test]
455    fn sampler_matches_the_sorting_reference() {
456        // Configs spanning: explicit top_k, nucleus with NO top_k (the geometric-grow path), min_p,
457        // a top_p of 1.0 (nothing truncated), and a k larger than the vocabulary.
458        let configs: [(Option<usize>, f32, Option<f32>, f32); 7] = [
459            (Some(20), 0.8, None, 0.7), // the claim-extractor's settings
460            (Some(1), 1.0, None, 1.0),  // k=1 ⇒ effectively greedy
461            (Some(50), 0.95, Some(0.05), 1.2),
462            (None, 0.8, None, 0.7), // nucleus, no k ⇒ must grow the candidate set
463            (None, 0.999, None, 1.0), // nucleus that needs nearly the whole distribution
464            (None, 1.0, Some(0.1), 0.5),
465            (Some(10_000), 0.9, None, 1.0), // k far beyond what top_p keeps
466        ];
467        // A near-uniform distribution is the worst case for the grow path (no small nucleus exists).
468        for &flat in &[false, true] {
469            for &n in &[64usize, 1024, 32_000] {
470                for (ci, &(top_k, top_p, min_p, temp)) in configs.iter().enumerate() {
471                    let lg = logits(n, 0xC0FFEE + n as u64 + ci as u64, flat);
472                    for seed in 0..24u64 {
473                        let (mut r1, mut r2) = (seed * 977 + 1, seed * 977 + 1);
474                        let got = sample(&lg, temp, top_k, top_p, min_p, &mut r1);
475                        let want = sample_by_sorting(&lg, temp, top_k, top_p, min_p, &mut r2);
476                        assert_eq!(
477                            got, want,
478                            "n={n} flat={flat} cfg={ci} seed={seed}: selection drew {got}, sort drew {want}"
479                        );
480                        assert_eq!(r1, r2, "the RNG must advance identically");
481                    }
482                }
483            }
484        }
485    }
486
487    /// Ties must resolve the way the old STABLE sort did: lowest token id wins.
488    #[test]
489    fn ties_keep_the_lowest_token_id() {
490        let lg = vec![1.0f32; 500]; // every token identical ⇒ all ties
491        for seed in 0..16u64 {
492            let (mut r1, mut r2) = (seed + 7, seed + 7);
493            assert_eq!(
494                sample(&lg, 1.0, Some(3), 1.0, None, &mut r1),
495                sample_by_sorting(&lg, 1.0, Some(3), 1.0, None, &mut r2),
496            );
497        }
498    }
499}
500
501/// Prompt-lookup draft source: propose the `k` tokens that followed the longest matching suffix
502/// n-gram (n ≤ 3) of `all` earlier in `all` — pure token-id matching, no linguistic data. The
503/// most recent earlier occurrence wins (locality: generations repeat their own recent
504/// structure), and a short found continuation is padded by cycling. Returns empty when no
505/// n-gram recurs. Wrong drafts only cost speed under greedy exact-match verify, never
506/// correctness. Shared by serving (PLD replace-form) and the app-tier constrained drafter.
507pub fn prompt_lookup_drafts(all: &[u32], k: usize) -> Vec<u32> {
508    let n = all.len();
509    for glen in (1..=3.min(n.saturating_sub(1))).rev() {
510        let suffix = &all[n - glen..];
511        for start in (0..n - glen).rev() {
512            if &all[start..start + glen] == suffix {
513                let cont = &all[start + glen..(start + glen + k).min(n)];
514                if !cont.is_empty() {
515                    let mut d = cont.to_vec();
516                    while d.len() < k {
517                        d.push(d[d.len() % cont.len().max(1)]);
518                    }
519                    return d;
520                }
521            }
522        }
523    }
524    Vec::new()
525}