Skip to main content

cortiq_engine/
sampler.rs

1//! Token sampling — temperature, top-p, top-k, min-p, repetition penalty.
2//!
3//! Randomness comes from an explicit SplitMix64 PRNG carried by the
4//! caller: reproducible with a seed, unbiased across the whole CDF
5//! (the v1 `subsec_nanos` source could never pick past ~23% of it).
6
7use serde::{Deserialize, Serialize};
8
9/// SplitMix64 — tiny, fast, statistically solid for sampling.
10#[derive(Debug, Clone)]
11pub struct SplitMix64 {
12    state: u64,
13}
14
15/// Reusable per-pipeline sampling workspace. The epoch table lets the
16/// repetition penalty visit each token id once without allocating a HashSet
17/// or clearing a vocab-sized boolean vector on every decode step.
18#[derive(Debug, Default)]
19pub struct SamplerScratch {
20    seen_epoch: Vec<u32>,
21    epoch: u32,
22}
23
24impl SamplerScratch {
25    fn begin_seen(&mut self, vocab_size: usize) -> u32 {
26        if self.seen_epoch.len() < vocab_size {
27            self.seen_epoch.resize(vocab_size, 0);
28        }
29        self.epoch = self.epoch.wrapping_add(1);
30        if self.epoch == 0 {
31            self.seen_epoch.fill(0);
32            self.epoch = 1;
33        }
34        self.epoch
35    }
36}
37
38impl SplitMix64 {
39    pub fn new(seed: u64) -> Self {
40        Self { state: seed }
41    }
42
43    /// Seed from OS entropy (address-space + time mix) when none given.
44    pub fn from_entropy() -> Self {
45        let t = std::time::SystemTime::now()
46            .duration_since(std::time::UNIX_EPOCH)
47            .unwrap_or_default();
48        let addr = Box::into_raw(Box::new(0u8)) as u64;
49        // SAFETY: pointer came from Box::into_raw just above.
50        unsafe { drop(Box::from_raw(addr as *mut u8)) };
51        Self::new(t.as_nanos() as u64 ^ addr.rotate_left(17) ^ 0x9E3779B97F4A7C15)
52    }
53
54    #[inline]
55    pub fn next_u64(&mut self) -> u64 {
56        self.state = self.state.wrapping_add(0x9E3779B97F4A7C15);
57        let mut z = self.state;
58        z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
59        z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
60        z ^ (z >> 31)
61    }
62
63    /// Uniform f32 in [0, 1).
64    #[inline]
65    pub fn next_f32(&mut self) -> f32 {
66        (self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
67    }
68}
69
70/// Sampling configuration.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct SamplerConfig {
73    pub temperature: f32,
74    pub top_p: f32,
75    pub top_k: u32,
76    pub repetition_penalty: f32,
77    pub min_p: f32,
78    /// Fixed seed for reproducible generation (None = entropy).
79    #[serde(default)]
80    pub seed: Option<u64>,
81    /// Token IDs to suppress (force logit to -inf).
82    #[serde(default)]
83    pub suppress_tokens: Vec<u32>,
84}
85
86impl Default for SamplerConfig {
87    fn default() -> Self {
88        Self {
89            temperature: 0.7,
90            top_p: 0.9,
91            top_k: 40,
92            repetition_penalty: 1.1,
93            min_p: 0.05,
94            seed: None,
95            suppress_tokens: Vec::new(),
96        }
97    }
98}
99
100/// Sample next token from logits. Chain order is fixed:
101/// rep-penalty → temperature → softmax → min-p → top-k → top-p → sample.
102pub fn sample(
103    logits: &[f32],
104    config: &SamplerConfig,
105    past_tokens: &[u32],
106    rng: &mut SplitMix64,
107) -> u32 {
108    let mut scratch = SamplerScratch::default();
109    sample_with_scratch(logits, config, past_tokens, rng, &mut scratch)
110}
111
112/// Sampling entry point for hot decode loops with reusable scratch storage.
113pub fn sample_with_scratch(
114    logits: &[f32],
115    config: &SamplerConfig,
116    past_tokens: &[u32],
117    rng: &mut SplitMix64,
118    scratch: &mut SamplerScratch,
119) -> u32 {
120    if config.temperature < 1e-6
121        && config.repetition_penalty == 1.0
122        && config.suppress_tokens.is_empty()
123    {
124        return argmax(logits);
125    }
126    let mut probs = logits.to_vec();
127
128    for &tok in &config.suppress_tokens {
129        if (tok as usize) < probs.len() {
130            probs[tok as usize] = f32::NEG_INFINITY;
131        }
132    }
133
134    if config.repetition_penalty != 1.0 {
135        apply_repetition_penalty(&mut probs, past_tokens, config.repetition_penalty, scratch);
136    }
137
138    if config.temperature < 1e-6 {
139        return argmax(&probs); // greedy
140    }
141    if config.temperature != 1.0 {
142        for p in probs.iter_mut() {
143            *p /= config.temperature;
144        }
145    }
146
147    softmax_inplace(&mut probs);
148
149    if config.min_p > 0.0 {
150        let max_prob = probs.iter().cloned().fold(0.0f32, f32::max);
151        let threshold = max_prob * config.min_p;
152        for p in probs.iter_mut() {
153            if *p < threshold {
154                *p = 0.0;
155            }
156        }
157    }
158
159    if config.top_k > 0 && (config.top_k as usize) < probs.len() {
160        apply_top_k(&mut probs, config.top_k as usize);
161    }
162
163    if config.top_p < 1.0 && config.top_p > 0.0 {
164        apply_top_p(&mut probs, config.top_p);
165    }
166
167    let sum: f32 = probs.iter().sum();
168    if sum > 0.0 {
169        for p in probs.iter_mut() {
170            *p /= sum;
171        }
172    } else {
173        // Everything filtered out — fall back to greedy over original logits.
174        return argmax(logits);
175    }
176
177    categorical_sample(&probs, rng.next_f32())
178}
179
180/// Greedy: index of the maximum value.
181pub fn argmax(values: &[f32]) -> u32 {
182    values
183        .iter()
184        .enumerate()
185        .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
186        .map(|(i, _)| i as u32)
187        .unwrap_or(0)
188}
189
190fn softmax_inplace(logits: &mut [f32]) {
191    let max_val = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
192    let mut sum = 0.0f32;
193    for v in logits.iter_mut() {
194        *v = (*v - max_val).exp();
195        sum += *v;
196    }
197    if sum > 0.0 {
198        for v in logits.iter_mut() {
199            *v /= sum;
200        }
201    }
202}
203
204fn apply_repetition_penalty(
205    logits: &mut [f32],
206    past_tokens: &[u32],
207    penalty: f32,
208    scratch: &mut SamplerScratch,
209) {
210    let epoch = scratch.begin_seen(logits.len());
211    for &tok in past_tokens {
212        let idx = tok as usize;
213        if idx < logits.len() && scratch.seen_epoch[idx] != epoch {
214            scratch.seen_epoch[idx] = epoch;
215            if logits[idx] > 0.0 {
216                logits[idx] /= penalty;
217            } else {
218                logits[idx] *= penalty;
219            }
220        }
221    }
222}
223
224/// Keep the k highest-probability tokens (plus exact ties at the
225/// threshold), zero the rest. Selection, not a full vocab sort — the
226/// old double `sort_by` over ~150k probs was ~1ms of pure per-token
227/// overhead (roadmap §3 P0).
228fn apply_top_k(probs: &mut [f32], k: usize) {
229    if k == 0 || k >= probs.len() {
230        return;
231    }
232    let mut sel: Vec<f32> = probs.to_vec();
233    // k-th largest = (k-1)-th index in a descending partition.
234    let (_, kth, _) = sel.select_nth_unstable_by(k - 1, |a, b| {
235        b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)
236    });
237    let threshold = *kth;
238    for p in probs.iter_mut() {
239        if *p < threshold {
240            *p = 0.0;
241        }
242    }
243}
244
245/// Nucleus: keep the smallest prefix of tokens whose cumulative
246/// probability reaches top_p. Only surviving (non-zero) candidates are
247/// sorted — after top-k that is ≤ k elements, not the whole vocab; the
248/// kept set is marked in-place instead of a per-token HashSet.
249fn apply_top_p(probs: &mut [f32], top_p: f32) {
250    let mut indexed: Vec<(usize, f32)> = probs
251        .iter()
252        .copied()
253        .enumerate()
254        .filter(|&(_, p)| p > 0.0)
255        .collect();
256    indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
257
258    let mut cumsum = 0.0f32;
259    let mut cutoff_idx = indexed.len();
260    for (i, &(_, prob)) in indexed.iter().enumerate() {
261        cumsum += prob;
262        if cumsum >= top_p {
263            cutoff_idx = i + 1;
264            break;
265        }
266    }
267
268    // Zero the dropped tail directly — indices, not membership tests.
269    for &(i, _) in &indexed[cutoff_idx..] {
270        probs[i] = 0.0;
271    }
272}
273
274/// Inverse-CDF sampling with an externally supplied uniform r ∈ [0, 1).
275fn categorical_sample(probs: &[f32], r: f32) -> u32 {
276    let mut cumsum = 0.0f32;
277    for (i, &p) in probs.iter().enumerate() {
278        cumsum += p;
279        if r < cumsum {
280            return i as u32;
281        }
282    }
283    probs.iter().rposition(|&p| p > 0.0).unwrap_or(0) as u32
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn test_argmax() {
292        let logits = vec![0.1, 0.5, 0.3, 0.9, 0.2];
293        assert_eq!(argmax(&logits), 3);
294    }
295
296    #[test]
297    fn test_greedy_sampling() {
298        let logits = vec![1.0, 5.0, 2.0, 3.0];
299        let config = SamplerConfig {
300            temperature: 0.0,
301            ..Default::default()
302        };
303        let mut rng = SplitMix64::new(1);
304        assert_eq!(sample(&logits, &config, &[], &mut rng), 1);
305    }
306
307    #[test]
308    fn test_softmax() {
309        let mut logits = vec![1.0, 2.0, 3.0];
310        softmax_inplace(&mut logits);
311        let sum: f32 = logits.iter().sum();
312        assert!((sum - 1.0).abs() < 1e-5);
313        assert!(logits[2] > logits[1] && logits[1] > logits[0]);
314    }
315
316    #[test]
317    fn test_repetition_penalty() {
318        let mut logits = vec![1.0, 2.0, 3.0, 4.0];
319        let mut scratch = SamplerScratch::default();
320        apply_repetition_penalty(&mut logits, &[1, 3], 2.0, &mut scratch);
321        assert_eq!(logits, vec![1.0, 1.0, 3.0, 2.0]);
322    }
323
324    #[test]
325    fn repetition_penalty_applies_once_per_unique_token() {
326        let mut logits = vec![1.0, 4.0, -6.0];
327        let mut scratch = SamplerScratch::default();
328        apply_repetition_penalty(&mut logits, &[1, 1, 2, 1, 2], 2.0, &mut scratch);
329        assert_eq!(logits, vec![1.0, 2.0, -12.0]);
330    }
331
332    #[test]
333    fn top_k_keeps_exactly_k() {
334        let mut probs = vec![0.1, 0.4, 0.05, 0.3, 0.15];
335        apply_top_k(&mut probs, 2);
336        let kept = probs.iter().filter(|&&p| p > 0.0).count();
337        assert_eq!(kept, 2, "top-k must keep exactly k (was k+1 in v1)");
338        assert!(probs[1] > 0.0 && probs[3] > 0.0);
339    }
340
341    #[test]
342    fn rng_reaches_full_cdf() {
343        // v1 bug: r < 0.233 always, so the CDF tail was unreachable.
344        // With uniform probs the LAST index must be sampled sometimes.
345        let probs = vec![0.25f32; 4];
346        let mut rng = SplitMix64::new(42);
347        let mut hits = [0usize; 4];
348        for _ in 0..4000 {
349            let i = categorical_sample(&probs, rng.next_f32()) as usize;
350            hits[i] += 1;
351        }
352        for (i, &h) in hits.iter().enumerate() {
353            assert!(h > 700, "index {i} sampled only {h}/4000 — biased RNG");
354        }
355    }
356
357    #[test]
358    fn same_seed_same_sequence() {
359        let logits: Vec<f32> = (0..32).map(|i| (i as f32 * 0.37).sin()).collect();
360        let config = SamplerConfig {
361            temperature: 1.0,
362            seed: Some(7),
363            ..Default::default()
364        };
365        let run = |seed: u64| -> Vec<u32> {
366            let mut rng = SplitMix64::new(seed);
367            (0..16)
368                .map(|_| sample(&logits, &config, &[], &mut rng))
369                .collect()
370        };
371        assert_eq!(run(7), run(7), "same seed must reproduce");
372        assert_ne!(run(7), run(8), "different seed must differ");
373    }
374}