Skip to main content

ferrox_models/
sampling.rs

1//! Token sampling from a decoder's output logits: temperature, top-k,
2//! top-p (nucleus), and repetition penalty, on top of the greedy argmax
3//! ferrox previously always used unconditionally (see
4//! `crate::speculative`, which still uses plain greedy argmax directly
5//! since its quality-neutrality proof depends on exactly matching
6//! greedy decode -- sampling is a deliberately separate, opt-in path).
7//!
8//! No external `rand` dependency: a small xorshift64* generator (the
9//! same algorithm `Decoder::new_random_small`'s test-only `Lcg` already
10//! uses in `decoder.rs`) is enough for sampling and keeps the
11//! dependency tree the same minimal, pure-Rust shape as the rest of
12//! this crate.
13
14/// Sampling parameters for one generation request. `temperature <= 0.0`
15/// means "sample nothing, take the greedy argmax" -- the same
16/// deterministic behavior ferrox always had before this module existed.
17#[derive(Debug, Clone)]
18pub struct SamplingParams {
19    pub temperature: f32,
20    /// Nucleus sampling threshold in (0.0, 1.0]. 1.0 disables top-p
21    /// filtering (every token with nonzero probability is eligible).
22    pub top_p: f32,
23    /// Keep only the `top_k` highest-probability tokens before
24    /// sampling. 0 disables top-k filtering.
25    pub top_k: usize,
26    /// > 1.0 discourages repeating a token already in `history`; 1.0
27    /// > disables repetition penalty. Uses the standard convention
28    /// > (divide positive logits, multiply negative ones) so the penalty
29    /// > always pushes toward *less* likely, regardless of logit sign.
30    pub repetition_penalty: f32,
31    /// OpenAI-style presence penalty: subtract from logits of tokens
32    /// that already appeared in `history` (once per distinct token).
33    pub presence_penalty: f32,
34    /// OpenAI-style frequency penalty: subtract `frequency_penalty *
35    /// count` from logits for each token id seen in `history`.
36    pub frequency_penalty: f32,
37}
38
39impl Default for SamplingParams {
40    /// Greedy decoding: identical behavior to ferrox's original
41    /// argmax-only generation loop.
42    fn default() -> Self {
43        SamplingParams {
44            temperature: 0.0,
45            top_p: 1.0,
46            top_k: 0,
47            repetition_penalty: 1.0,
48            presence_penalty: 0.0,
49            frequency_penalty: 0.0,
50        }
51    }
52}
53
54/// Zeroes out logits a caller wants to forbid, in place, before the
55/// sampler looks at them. Used by JSON-object mode to keep generation
56/// inside the grammar.
57pub type LogitMask<'a> = &'a mut dyn FnMut(&mut [f32]);
58
59/// A small, seedable xorshift64* generator. Not cryptographically
60/// secure -- sampling doesn't need that -- but reproducible given a
61/// seed, which greedy argmax already was for free.
62pub struct Sampler {
63    state: u64,
64}
65
66impl Sampler {
67    pub fn new(seed: u64) -> Self {
68        // xorshift64* requires a nonzero seed.
69        Sampler {
70            state: if seed == 0 { 0x9E3779B97F4A7C15 } else { seed },
71        }
72    }
73
74    fn next_u64(&mut self) -> u64 {
75        self.state ^= self.state << 13;
76        self.state ^= self.state >> 7;
77        self.state ^= self.state << 17;
78        self.state
79    }
80
81    /// Uniform float in [0.0, 1.0).
82    fn next_f32(&mut self) -> f32 {
83        (self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
84    }
85
86    /// Samples one token id from `logits`, given `params` and the
87    /// already-generated `history` (for repetition penalty). Falls back
88    /// to plain greedy argmax when `params.temperature <= 0.0`.
89    ///
90    /// A length-1 `logits` vector is treated as a precomputed greedy token
91    /// id (`logits[0] as usize`) — used by the Metal dense-stack path that
92    /// returns GPU argmax instead of downloading the full vocab.
93    pub fn sample(&mut self, logits: &[f32], params: &SamplingParams, history: &[usize]) -> usize {
94        self.sample_with_mask(logits, params, history, None)
95    }
96
97    /// Like [`Self::sample`], but optionally zeroes disallowed logits via
98    /// `mask` before argmax / nucleus sampling (used for JSON-object mode).
99    pub fn sample_with_mask(
100        &mut self,
101        logits: &[f32],
102        params: &SamplingParams,
103        history: &[usize],
104        mut mask: Option<LogitMask<'_>>,
105    ) -> usize {
106        if params.temperature <= 0.0 && mask.is_none() {
107            if logits.len() == 1 {
108                return logits[0] as usize;
109            }
110            let mut scores = logits.to_vec();
111            apply_history_penalties(&mut scores, params, history);
112            return argmax(&scores);
113        }
114
115        let mut scores: Vec<f32> = logits.to_vec();
116        apply_history_penalties(&mut scores, params, history);
117
118        if let Some(m) = mask.as_mut() {
119            m(&mut scores);
120        }
121
122        if params.temperature <= 0.0 {
123            if scores.len() == 1 {
124                return scores[0] as usize;
125            }
126            return argmax(&scores);
127        }
128
129        for s in scores.iter_mut() {
130            *s /= params.temperature;
131        }
132
133        let mut probs = softmax(&scores);
134
135        if params.top_k > 0 && params.top_k < probs.len() {
136            let mut idx: Vec<usize> = (0..probs.len()).collect();
137            idx.sort_unstable_by(|&a, &b| probs[b].partial_cmp(&probs[a]).unwrap());
138            for &i in idx.iter().skip(params.top_k) {
139                probs[i] = 0.0;
140            }
141        }
142
143        if params.top_p < 1.0 {
144            let mut idx: Vec<usize> = (0..probs.len()).collect();
145            idx.sort_unstable_by(|&a, &b| probs[b].partial_cmp(&probs[a]).unwrap());
146            let mut cumulative = 0.0f32;
147            let mut cutoff = idx.len();
148            for (rank, &i) in idx.iter().enumerate() {
149                cumulative += probs[i];
150                if cumulative >= params.top_p {
151                    cutoff = rank + 1;
152                    break;
153                }
154            }
155            for &i in idx.iter().skip(cutoff) {
156                probs[i] = 0.0;
157            }
158        }
159
160        let total: f32 = probs.iter().sum();
161        if total <= 0.0 {
162            // Every candidate got filtered to zero (degenerate params);
163            // fall back to greedy rather than sampling from nothing.
164            return argmax(logits);
165        }
166        for p in probs.iter_mut() {
167            *p /= total;
168        }
169
170        let draw = self.next_f32();
171        let mut cumulative = 0.0f32;
172        for (i, &p) in probs.iter().enumerate() {
173            cumulative += p;
174            if draw < cumulative {
175                return i;
176            }
177        }
178        // Floating-point rounding may leave `draw` fractionally above
179        // the final cumulative sum; the last nonzero-probability token
180        // is the correct fallback, not index 0.
181        probs
182            .iter()
183            .enumerate()
184            .rev()
185            .find(|&(_, &p)| p > 0.0)
186            .map(|(i, _)| i)
187            .unwrap_or(0)
188    }
189}
190
191fn apply_history_penalties(scores: &mut [f32], params: &SamplingParams, history: &[usize]) {
192    if params.repetition_penalty != 1.0 {
193        for &tok in history {
194            if let Some(s) = scores.get_mut(tok) {
195                *s = if *s > 0.0 {
196                    *s / params.repetition_penalty
197                } else {
198                    *s * params.repetition_penalty
199                };
200            }
201        }
202    }
203    if params.presence_penalty != 0.0 || params.frequency_penalty != 0.0 {
204        let mut counts = std::collections::HashMap::<usize, usize>::new();
205        for &tok in history {
206            *counts.entry(tok).or_insert(0) += 1;
207        }
208        for (tok, count) in counts {
209            if let Some(s) = scores.get_mut(tok) {
210                *s -= params.frequency_penalty * count as f32;
211                *s -= params.presence_penalty;
212            }
213        }
214    }
215}
216
217fn argmax(logits: &[f32]) -> usize {
218    logits
219        .iter()
220        .enumerate()
221        .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
222        .map(|(i, _)| i)
223        .unwrap_or(0)
224}
225
226fn softmax(logits: &[f32]) -> Vec<f32> {
227    let max = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
228    let exps: Vec<f32> = logits.iter().map(|&l| (l - max).exp()).collect();
229    let sum: f32 = exps.iter().sum();
230    if sum <= 0.0 {
231        vec![1.0 / logits.len().max(1) as f32; logits.len()]
232    } else {
233        exps.into_iter().map(|e| e / sum).collect()
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn temperature_zero_accepts_precomputed_argmax_singleton() {
243        let mut sampler = Sampler::new(1);
244        let params = SamplingParams::default();
245        assert_eq!(sampler.sample(&[42.0], &params, &[]), 42);
246        // Non-greedy must not treat a singleton as a token id.
247        let sampled = SamplingParams {
248            temperature: 0.8,
249            ..SamplingParams::default()
250        };
251        // Softmax of a single logit → only token 0 is eligible.
252        assert_eq!(sampler.sample(&[42.0], &sampled, &[]), 0);
253    }
254
255    #[test]
256    fn temperature_zero_is_deterministic_greedy_argmax() {
257        let logits = vec![0.1, 0.9, 0.3, -0.2];
258        let params = SamplingParams::default();
259        let mut sampler = Sampler::new(42);
260        assert_eq!(sampler.sample(&logits, &params, &[]), 1);
261        // Must be deterministic regardless of RNG state advancing.
262        assert_eq!(sampler.sample(&logits, &params, &[]), 1);
263    }
264
265    #[test]
266    fn high_temperature_can_pick_a_non_argmax_token_over_many_draws() {
267        let logits = vec![1.0, 1.0, 1.0, 1.0];
268        let params = SamplingParams {
269            temperature: 1.0,
270            ..SamplingParams::default()
271        };
272        let mut sampler = Sampler::new(7);
273        let mut seen = std::collections::HashSet::new();
274        for _ in 0..200 {
275            seen.insert(sampler.sample(&logits, &params, &[]));
276        }
277        assert!(
278            seen.len() > 1,
279            "uniform logits at temperature=1.0 must produce more than one distinct token across 200 draws"
280        );
281    }
282
283    #[test]
284    fn top_k_one_is_equivalent_to_greedy() {
285        let logits = vec![0.1, 0.9, 0.3, -0.2];
286        let params = SamplingParams {
287            temperature: 1.0,
288            top_k: 1,
289            ..SamplingParams::default()
290        };
291        let mut sampler = Sampler::new(123);
292        for _ in 0..20 {
293            assert_eq!(sampler.sample(&logits, &params, &[]), 1);
294        }
295    }
296
297    #[test]
298    fn top_p_near_zero_is_equivalent_to_greedy() {
299        let logits = vec![0.1, 5.0, 0.3, -0.2];
300        let params = SamplingParams {
301            temperature: 1.0,
302            top_p: 0.001,
303            ..SamplingParams::default()
304        };
305        let mut sampler = Sampler::new(9);
306        for _ in 0..20 {
307            assert_eq!(sampler.sample(&logits, &params, &[]), 1);
308        }
309    }
310
311    #[test]
312    fn presence_and_frequency_penalties_reduce_seen_token_logits() {
313        let logits = vec![0.0, 5.0, 0.0];
314        let params = SamplingParams {
315            temperature: 1.0,
316            presence_penalty: 10.0,
317            frequency_penalty: 0.0,
318            ..SamplingParams::default()
319        };
320        let mut sampler = Sampler::new(1);
321        let mut counts = [0usize; 3];
322        for _ in 0..500 {
323            counts[sampler.sample(&logits, &params, &[1])] += 1;
324        }
325        assert!(
326            counts[1] < 250,
327            "presence_penalty should discourage token 1; counts={counts:?}"
328        );
329
330        let params = SamplingParams {
331            temperature: 1.0,
332            presence_penalty: 0.0,
333            frequency_penalty: 10.0,
334            ..SamplingParams::default()
335        };
336        let mut sampler = Sampler::new(2);
337        counts = [0; 3];
338        for _ in 0..500 {
339            counts[sampler.sample(&logits, &params, &[1, 1, 1])] += 1;
340        }
341        assert!(
342            counts[1] < 250,
343            "frequency_penalty should discourage repeated token 1; counts={counts:?}"
344        );
345    }
346
347    #[test]
348    fn repetition_penalty_reduces_probability_of_recently_seen_token() {
349        let logits = vec![0.0, 5.0, 0.0];
350        let params = SamplingParams {
351            temperature: 1.0,
352            repetition_penalty: 1000.0,
353            ..SamplingParams::default()
354        };
355        let mut sampler = Sampler::new(3);
356        let mut counts = [0usize; 3];
357        for _ in 0..500 {
358            counts[sampler.sample(&logits, &params, &[1])] += 1;
359        }
360        assert!(
361            counts[1] < 250,
362            "heavily penalizing token 1 (already in history) should make it far less likely than its raw logit alone would suggest; got counts={counts:?}"
363        );
364    }
365
366    #[test]
367    fn degenerate_all_zero_probability_falls_back_to_greedy() {
368        // top_k=1 combined with a top_p that would exclude even that
369        // one surviving token is a contradictory/degenerate
370        // configuration; must not panic or sample index 0 blindly.
371        let logits = vec![0.1, 0.9, 0.3, -0.2];
372        let params = SamplingParams {
373            temperature: 1.0,
374            top_k: 1,
375            top_p: 1.0,
376            ..SamplingParams::default()
377        };
378        let mut sampler = Sampler::new(1);
379        assert_eq!(sampler.sample(&logits, &params, &[]), 1);
380    }
381}