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