Skip to main content

kopitiam_runtime/
sampling.rs

1//! Turning a row of logits into a token id.
2//!
3//! # Two samplers, one trait
4//!
5//! [`GreedySampler`] (`argmax`) is deterministic and repetitive: it always
6//! picks the single highest-scoring token, so the same prompt always
7//! produces the same completion, but real conversation needs variety a
8//! pure maximum can never produce. [`StochasticSampler`] is the
9//! alternative every practical LLM serving stack actually uses —
10//! temperature, top-k, top-p (nucleus), min-p, and a repetition penalty,
11//! composed as a *pipeline* of logit transforms rather than a pile of
12//! `if`-branches. That pipeline shape (not a monolithic "sample" function)
13//! is deliberate and mirrors how `llama.cpp` models its own sampler chain:
14//! each stage does one well-defined thing to a `[f32]` of per-token scores
15//! (mask out excluded tokens with [`f32::NEG_INFINITY`], or rescale
16//! surviving ones), stages compose in any order a caller chooses, and each
17//! stage is unit-testable in isolation against a hand-computed
18//! distribution — see this module's tests for exactly that.
19//!
20//! # Why a hand-rolled PRNG instead of `rand`
21//!
22//! Stochastic sampling is meaningless without randomness, but "random" and
23//! "deterministic behaviour" (CLAUDE.md's standing requirement) are not in
24//! tension here: a PRNG is a pure function of its seed and call count, so
25//! seeding it explicitly makes the *whole* generation loop reproducible —
26//! same seed, same prompt, same model, same token sequence, forever. That
27//! is why [`SamplingConfig::seed`] exists and why it is mandatory rather
28//! than "defaults to system entropy": an unseedable sampler cannot be unit
29//! tested (there would be no way to assert *which* token comes out), and
30//! CLAUDE.md is explicit that AI-adjacent or randomized workflows still
31//! owe the platform reproducibility. [`Rng`] is xorshift64* — about a
32//! dozen lines, no external crate, good enough statistical quality for
33//! sampling a few thousand tokens (it is not used for anything
34//! cryptographic) — which is a better fit for this workspace's "avoid
35//! unnecessary dependencies" rule than pulling in `rand`'s dependency tree
36//! for one struct's worth of functionality.
37
38/// Chooses the next token id from one row of logits (length `vocab_size`).
39pub trait Sampler {
40    fn sample(&mut self, logits: &[f32]) -> u32;
41}
42
43/// Always picks the highest-scoring token: `argmax(logits)`.
44#[derive(Debug, Clone, Copy, Default)]
45pub struct GreedySampler;
46
47impl Sampler for GreedySampler {
48    fn sample(&mut self, logits: &[f32]) -> u32 {
49        greedy_argmax(logits)
50    }
51}
52
53/// `argmax(logits)`, ties broken toward the lowest id (the first maximum
54/// encountered) — a `PartialOrd` tie-break rule that is total and
55/// deterministic even in the presence of `NaN` (`f32::partial_cmp`'s `None`
56/// case), unlike an `assert`-free `.max_by(...)` over raw `f32`, which
57/// would panic or silently misbehave on a `NaN` logit.
58pub fn greedy_argmax(logits: &[f32]) -> u32 {
59    assert!(!logits.is_empty(), "greedy_argmax requires at least one logit");
60    let mut best_idx = 0usize;
61    let mut best_val = logits[0];
62    for (idx, &val) in logits.iter().enumerate().skip(1) {
63        if val > best_val {
64            best_idx = idx;
65            best_val = val;
66        }
67    }
68    best_idx as u32
69}
70
71/// A small, seedable, dependency-free PRNG: xorshift64*
72/// (Marsaglia/Vigna). Not cryptographic — nothing in this crate needs
73/// that — but a fixed seed deterministically reproduces its entire output
74/// sequence, which is the one property [`StochasticSampler`] actually
75/// needs (see this module's docs).
76#[derive(Debug, Clone)]
77struct Rng(u64);
78
79impl Rng {
80    /// A seed of `0` would leave xorshift's state permanently `0` (its one
81    /// fixed point — every subsequent xor-shift of `0` is `0`), so it is
82    /// remapped to an arbitrary nonzero constant. Every other seed is used
83    /// as-is, so distinct nonzero seeds still produce distinct sequences.
84    fn new(seed: u64) -> Self {
85        Self(if seed == 0 { 0x9E37_79B9_7F4A_7C15 } else { seed })
86    }
87
88    fn next_u64(&mut self) -> u64 {
89        let mut x = self.0;
90        x ^= x >> 12;
91        x ^= x << 25;
92        x ^= x >> 27;
93        self.0 = x;
94        x.wrapping_mul(0x2545_F491_4F6C_DD1D)
95    }
96
97    /// A uniform `f32` in `[0, 1)`, built from the top 24 bits of
98    /// [`Self::next_u64`] (an `f32` mantissa only has 24 bits of precision
99    /// including the implicit leading one, so using more source bits than
100    /// that would not add resolution, only bias the low bits).
101    fn next_unit_f32(&mut self) -> f32 {
102        (self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
103    }
104}
105
106/// Configuration for [`StochasticSampler`]'s logit-transform pipeline.
107///
108/// Every threshold is `Option`-or-plain depending on whether "disabled" is
109/// a meaningful state: `top_k`/`top_p`/`min_p` default to `None` (that
110/// stage of the pipeline is skipped entirely), while `temperature` and
111/// `repeat_penalty` always run (a temperature of `1.0` and a repeat
112/// penalty of `1.0` are each that stage's identity value, so leaving them
113/// at their defaults is equivalent to skipping them, without needing a
114/// second `Option` layer).
115#[derive(Debug, Clone)]
116pub struct SamplingConfig {
117    /// Divides every logit before the final softmax. `1.0` (the default)
118    /// leaves the distribution's shape unchanged; values below `1.0`
119    /// sharpen it toward the top-scoring tokens, values above `1.0`
120    /// flatten it toward uniform. `<= 0.0` is treated as "skip stochastic
121    /// sampling entirely and fall back to [`greedy_argmax`]" — see
122    /// [`StochasticSampler::sample`]'s docs for why that is an exact
123    /// equivalence, not an approximation.
124    pub temperature: f32,
125    /// Keep only the `k` highest-scoring tokens before sampling. `None`
126    /// (the default) skips this stage.
127    pub top_k: Option<usize>,
128    /// Nucleus sampling: keep the smallest prefix of tokens (sorted by
129    /// probability, descending) whose cumulative probability is the first
130    /// to exceed `p`. `None` (the default) skips this stage.
131    pub top_p: Option<f32>,
132    /// Keep only tokens whose probability is at least `min_p` times the
133    /// single most likely token's probability. `None` (the default) skips
134    /// this stage.
135    pub min_p: Option<f32>,
136    /// Divides a previously-generated token's logit by this value if it
137    /// was positive, or multiplies it if negative — the standard
138    /// (Keskar et al., 2019, CTRL) repetition penalty. `1.0` (the default)
139    /// is the identity: no penalty.
140    pub repeat_penalty: f32,
141    /// How many of the most recently generated tokens
142    /// [`StochasticSampler`] remembers for [`Self::repeat_penalty`]'s
143    /// history window.
144    pub repeat_last_n: usize,
145    /// Seeds [`StochasticSampler`]'s internal PRNG — see this module's
146    /// docs for why this is mandatory rather than defaulting to system
147    /// entropy.
148    pub seed: u64,
149}
150
151impl Default for SamplingConfig {
152    fn default() -> Self {
153        Self {
154            temperature: 1.0,
155            top_k: None,
156            top_p: None,
157            min_p: None,
158            repeat_penalty: 1.0,
159            repeat_last_n: 64,
160            seed: 0,
161        }
162    }
163}
164
165/// Temperature / top-k / top-p / min-p / repetition-penalty sampling,
166/// composed as a pipeline of logit transforms (see this module's docs) and
167/// driven by a seeded [`Rng`].
168///
169/// # Pipeline order
170///
171/// [`StochasticSampler::sample`] applies, in this order: repetition
172/// penalty (needs the *raw* logit scale — see
173/// [`apply_repetition_penalty`]'s docs) -> top-k -> top-p -> min-p ->
174/// temperature -> softmax -> weighted-random draw. Top-k/top-p/min-p run
175/// *before* temperature deliberately: top-p and min-p compute a softmax
176/// internally to rank tokens by probability, and temperature changes how
177/// flat or peaked that probability distribution is — running them after
178/// temperature would make the nucleus/threshold decision depend on a
179/// rescaling that has not been "committed to" yet. This is the same
180/// ordering `llama.cpp`'s sampler chain uses.
181pub struct StochasticSampler {
182    config: SamplingConfig,
183    rng: Rng,
184    /// The most recent [`SamplingConfig::repeat_last_n`] sampled token
185    /// ids, oldest first. A `Vec` with an O(n) front-remove is fine here:
186    /// `repeat_last_n` is a small, bounded window (tens to low hundreds of
187    /// tokens), not the whole generated sequence.
188    history: Vec<u32>,
189}
190
191impl StochasticSampler {
192    pub fn new(config: SamplingConfig) -> Self {
193        let rng = Rng::new(config.seed);
194        Self { config, rng, history: Vec::new() }
195    }
196}
197
198impl Sampler for StochasticSampler {
199    fn sample(&mut self, logits: &[f32]) -> u32 {
200        assert!(!logits.is_empty(), "StochasticSampler::sample requires at least one logit");
201        let mut work = logits.to_vec();
202        apply_repetition_penalty(&mut work, &self.history, self.config.repeat_penalty);
203
204        // temperature <= 0.0 has no meaningful "divide by temperature"
205        // reading (0.0 is a division by zero; negative would invert the
206        // distribution's ranking) -- the only sensible interpretation is
207        // "as sharp as possible", i.e. plain argmax. This is not a
208        // fallback bolted on for safety: it is what makes
209        // `StochasticSampler { temperature: 0.0, .. }` an exact drop-in
210        // replacement for `GreedySampler` (see
211        // `tests::temperature_zero_degrades_exactly_to_greedy`), so a
212        // caller can switch between "deterministic" and "stochastic"
213        // generation by changing one number instead of swapping sampler
214        // types.
215        let chosen = if self.config.temperature <= 0.0 {
216            greedy_argmax(&work)
217        } else {
218            if let Some(k) = self.config.top_k {
219                apply_top_k(&mut work, k);
220            }
221            if let Some(p) = self.config.top_p {
222                apply_top_p(&mut work, p);
223            }
224            if let Some(mp) = self.config.min_p {
225                apply_min_p(&mut work, mp);
226            }
227            apply_temperature(&mut work, self.config.temperature);
228            sample_from_logits(&work, &mut self.rng)
229        };
230
231        self.history.push(chosen);
232        let window = self.config.repeat_last_n.max(1);
233        if self.history.len() > window {
234            self.history.remove(0);
235        }
236        chosen
237    }
238}
239
240/// Rescales every logit belonging to a token in `history` — the standard
241/// (Keskar et al., 2019, "CTRL") repetition penalty: a positive logit is
242/// divided by `penalty`, a negative one is multiplied by it, so `penalty
243/// > 1.0` always pushes a previously-seen token's score down regardless of
244/// its sign, and `penalty == 1.0` is the identity (every other value in
245/// `history` is a no-op, and duplicate ids in `history` are naturally
246/// idempotent — applying the same rescale to the same logit twice would
247/// double-penalize it, but every id is only visited once here because the
248/// loop is driven by [`std::collections::HashSet`]-deduplicated ids, not
249/// by `history`'s raw entries).
250///
251/// Runs on raw logits, *before* top-k/top-p/temperature: those stages
252/// reason about relative rank and cumulative probability, both of which
253/// the penalty is specifically meant to disturb (nudging a repeated
254/// token's rank down); running it after would let a repeated token that
255/// already survived top-k/top-p keep its undiminished probability mass.
256fn apply_repetition_penalty(logits: &mut [f32], history: &[u32], penalty: f32) {
257    if penalty == 1.0 {
258        return;
259    }
260    let seen: std::collections::HashSet<u32> = history.iter().copied().collect();
261    for &id in &seen {
262        let Some(logit) = logits.get_mut(id as usize) else { continue };
263        *logit = if *logit > 0.0 { *logit / penalty } else { *logit * penalty };
264    }
265}
266
267/// Keeps only the `k` highest-scoring logits, masking every other one to
268/// [`f32::NEG_INFINITY`] (excluded from every later stage and from the
269/// final sample, since `exp(-inf) == 0.0`). `k == 0` or `k >=
270/// logits.len()` is a no-op — there is nothing to remove.
271fn apply_top_k(logits: &mut [f32], k: usize) {
272    if k == 0 || k >= logits.len() {
273        return;
274    }
275    let mut order: Vec<usize> = (0..logits.len()).collect();
276    // Descending by score; NaN cannot occur in a real logits row (a NaN
277    // forward pass is already a bug elsewhere), so `partial_cmp().unwrap()`
278    // is a deliberate "this should never happen" panic, not an
279    // unhandled-edge-case shortcut.
280    order.sort_unstable_by(|&a, &b| logits[b].partial_cmp(&logits[a]).unwrap());
281    for &idx in &order[k..] {
282        logits[idx] = f32::NEG_INFINITY;
283    }
284}
285
286/// Nucleus sampling: ranks tokens by probability (softmax of `logits`,
287/// [`f32::NEG_INFINITY`] entries already contributing zero probability),
288/// then keeps the smallest prefix whose cumulative probability is the
289/// *first* to exceed `p` — masking every token after that prefix to
290/// [`f32::NEG_INFINITY`].
291///
292/// # The off-by-one this function exists to get right
293///
294/// The classic bug here is excluding the token whose addition pushes the
295/// cumulative sum past `p`. It must be *included*: nucleus sampling's
296/// whole definition is "the smallest set of tokens whose probability sums
297/// to at least `p`", and excluding the crossing token would make the kept
298/// set's probability strictly less than `p` in general — the opposite of
299/// what "at least" means. This function's loop adds a token's probability
300/// to the running total *before* checking whether the threshold was
301/// crossed, and only then decides whether to stop, which is what keeps
302/// the crossing token in — see `tests::top_p_includes_the_crossing_token`
303/// for the pinned example.
304///
305/// `p >= 1.0` is a no-op (every token's cumulative probability trivially
306/// reaches 1.0, i.e. "keep everything").
307fn apply_top_p(logits: &mut [f32], p: f32) {
308    if p >= 1.0 {
309        return;
310    }
311    let probs = softmax_ignoring_masked(logits);
312    let mut order: Vec<usize> = (0..logits.len()).filter(|&i| probs[i] > 0.0).collect();
313    order.sort_unstable_by(|&a, &b| probs[b].partial_cmp(&probs[a]).unwrap());
314
315    let mut cumulative = 0.0f32;
316    let mut keep = order.len();
317    for (rank, &idx) in order.iter().enumerate() {
318        cumulative += probs[idx];
319        if cumulative > p {
320            keep = rank + 1; // include the crossing token itself.
321            break;
322        }
323    }
324    for &idx in &order[keep..] {
325        logits[idx] = f32::NEG_INFINITY;
326    }
327}
328
329/// Keeps only tokens whose probability is at least `min_p` times the
330/// single most likely token's probability — a relative floor rather than
331/// nucleus sampling's cumulative one. `min_p <= 0.0` is a no-op (every
332/// probability is `>= 0.0`, i.e. "keep everything"); the most likely token
333/// itself always survives (its own threshold is exactly its own
334/// probability).
335fn apply_min_p(logits: &mut [f32], min_p: f32) {
336    if min_p <= 0.0 {
337        return;
338    }
339    let probs = softmax_ignoring_masked(logits);
340    let max_prob = probs.iter().copied().fold(0.0f32, f32::max);
341    let threshold = min_p * max_prob;
342    for (idx, &pr) in probs.iter().enumerate() {
343        if pr < threshold {
344            logits[idx] = f32::NEG_INFINITY;
345        }
346    }
347}
348
349/// Divides every (non-masked) logit by `temperature`. Callers only reach
350/// this with `temperature > 0.0` — see [`StochasticSampler::sample`]'s
351/// docs for why `<= 0.0` is handled as a separate greedy path instead.
352fn apply_temperature(logits: &mut [f32], temperature: f32) {
353    for logit in logits.iter_mut() {
354        if logit.is_finite() {
355            *logit /= temperature;
356        }
357    }
358}
359
360/// Softmax over `logits`, treating [`f32::NEG_INFINITY`] entries (this
361/// pipeline's "excluded" marker) as exactly zero probability rather than
362/// `NaN`. Shifts by the maximum finite logit first for numerical
363/// stability, the same trick [`kopitiam_tensor::Tensor::softmax`] uses,
364/// reimplemented here on a plain slice because this pipeline runs on a
365/// `Vec<f32>` extracted from the model's logits row, not on a
366/// [`kopitiam_tensor::Tensor`] directly.
367fn softmax_ignoring_masked(logits: &[f32]) -> Vec<f32> {
368    let max = logits.iter().copied().fold(f32::NEG_INFINITY, f32::max);
369    if !max.is_finite() {
370        // Every logit is -inf (or empty): no valid token. Returning a
371        // uniform distribution is safer than dividing by a zero sum, and
372        // this should be unreachable in practice (top-k/top-p/min-p never
373        // mask every token — see their own docs).
374        let n = logits.len().max(1);
375        return vec![1.0 / n as f32; logits.len()];
376    }
377    let exps: Vec<f32> = logits.iter().map(|&l| if l.is_finite() { (l - max).exp() } else { 0.0 }).collect();
378    let sum: f32 = exps.iter().sum();
379    if sum <= 0.0 {
380        let n = logits.len().max(1);
381        return vec![1.0 / n as f32; logits.len()];
382    }
383    exps.into_iter().map(|e| e / sum).collect()
384}
385
386/// Draws one token id from `logits` via `softmax(logits)` and a uniform
387/// random draw from `rng`: walks token ids in order, accumulating
388/// probability, and returns the first id whose cumulative probability
389/// reaches the draw. Falls back to [`greedy_argmax`] (the highest-
390/// probability surviving token) if floating-point summation leaves the
391/// cumulative total a hair under `1.0` and the draw landed past it —
392/// exceedingly rare, but a `Vec` index panic on a `1 in 2^24` fluke is
393/// strictly worse than silently taking the single most likely outcome.
394fn sample_from_logits(logits: &[f32], rng: &mut Rng) -> u32 {
395    let probs = softmax_ignoring_masked(logits);
396    let draw = rng.next_unit_f32();
397    let mut cumulative = 0.0f32;
398    for (idx, &pr) in probs.iter().enumerate() {
399        cumulative += pr;
400        if draw < cumulative {
401            return idx as u32;
402        }
403    }
404    greedy_argmax(&probs)
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    // -- greedy_argmax / GreedySampler (pre-existing) --
412
413    #[test]
414    fn greedy_argmax_picks_the_highest_scoring_index() {
415        assert_eq!(greedy_argmax(&[0.1, 5.0, -3.0, 2.0]), 1);
416    }
417
418    #[test]
419    fn greedy_argmax_breaks_ties_toward_the_first_occurrence() {
420        assert_eq!(greedy_argmax(&[1.0, 3.0, 3.0, 0.0]), 1);
421    }
422
423    #[test]
424    fn greedy_argmax_handles_a_single_element() {
425        assert_eq!(greedy_argmax(&[42.0]), 0);
426    }
427
428    #[test]
429    fn greedy_sampler_is_deterministic_across_repeated_calls() {
430        let mut sampler = GreedySampler;
431        let logits = [0.5, 2.0, 1.0];
432        assert_eq!(sampler.sample(&logits), sampler.sample(&logits));
433    }
434
435    #[test]
436    #[should_panic]
437    fn greedy_argmax_rejects_empty_input() {
438        greedy_argmax(&[]);
439    }
440
441    // -- Rng --
442
443    #[test]
444    fn rng_with_the_same_seed_produces_the_same_sequence() {
445        let mut a = Rng::new(42);
446        let mut b = Rng::new(42);
447        let seq_a: Vec<u64> = (0..10).map(|_| a.next_u64()).collect();
448        let seq_b: Vec<u64> = (0..10).map(|_| b.next_u64()).collect();
449        assert_eq!(seq_a, seq_b);
450    }
451
452    #[test]
453    fn rng_with_different_seeds_produces_different_sequences() {
454        let mut a = Rng::new(1);
455        let mut b = Rng::new(2);
456        let seq_a: Vec<u64> = (0..10).map(|_| a.next_u64()).collect();
457        let seq_b: Vec<u64> = (0..10).map(|_| b.next_u64()).collect();
458        assert_ne!(seq_a, seq_b);
459    }
460
461    #[test]
462    fn rng_zero_seed_does_not_get_stuck_at_the_fixed_point() {
463        let mut rng = Rng::new(0);
464        let values: Vec<u64> = (0..5).map(|_| rng.next_u64()).collect();
465        assert!(values.iter().all(|&v| v != 0), "a stuck-at-zero PRNG would emit all zeros: {values:?}");
466    }
467
468    #[test]
469    fn rng_unit_f32_stays_within_zero_one() {
470        let mut rng = Rng::new(7);
471        for _ in 0..1000 {
472            let v = rng.next_unit_f32();
473            assert!((0.0..1.0).contains(&v), "{v} outside [0, 1)");
474        }
475    }
476
477    // -- apply_repetition_penalty --
478
479    #[test]
480    fn repetition_penalty_of_one_is_a_no_op() {
481        let mut logits = vec![1.0, -1.0, 2.0];
482        apply_repetition_penalty(&mut logits, &[0, 1, 2], 1.0);
483        assert_eq!(logits, vec![1.0, -1.0, 2.0]);
484    }
485
486    #[test]
487    fn repetition_penalty_divides_positive_and_multiplies_negative_logits() {
488        let mut logits = vec![4.0, -4.0, 10.0];
489        // Token 0 (positive logit) and token 1 (negative logit) are in
490        // history; token 2 is not and must be untouched.
491        apply_repetition_penalty(&mut logits, &[0, 1], 2.0);
492        assert_eq!(logits[0], 2.0); // 4.0 / 2.0
493        assert_eq!(logits[1], -8.0); // -4.0 * 2.0
494        assert_eq!(logits[2], 10.0); // untouched
495    }
496
497    #[test]
498    fn repetition_penalty_ignores_duplicate_history_entries() {
499        let mut logits = vec![8.0];
500        apply_repetition_penalty(&mut logits, &[0, 0, 0], 2.0);
501        assert_eq!(logits[0], 4.0, "repeated history entries must not compound the penalty");
502    }
503
504    // -- apply_top_k --
505
506    #[test]
507    fn top_k_keeps_exactly_the_k_highest_logits() {
508        let mut logits = vec![1.0, 5.0, 3.0, 4.0, 2.0];
509        apply_top_k(&mut logits, 2);
510        let kept: Vec<usize> = (0..5).filter(|&i| logits[i].is_finite()).collect();
511        assert_eq!(kept, vec![1, 3]); // values 5.0 and 4.0
512    }
513
514    #[test]
515    fn top_k_zero_is_a_no_op() {
516        let mut logits = vec![1.0, 2.0, 3.0];
517        apply_top_k(&mut logits, 0);
518        assert!(logits.iter().all(|v| v.is_finite()));
519    }
520
521    #[test]
522    fn top_k_at_or_above_the_length_is_a_no_op() {
523        let mut logits = vec![1.0, 2.0, 3.0];
524        apply_top_k(&mut logits, 3);
525        assert!(logits.iter().all(|v| v.is_finite()));
526        let mut logits2 = vec![1.0, 2.0, 3.0];
527        apply_top_k(&mut logits2, 10);
528        assert!(logits2.iter().all(|v| v.is_finite()));
529    }
530
531    // -- apply_top_p (the classic off-by-one) --
532
533    #[test]
534    fn top_p_includes_the_crossing_token() {
535        // Probabilities (after softmax) chosen to land exactly on this
536        // module's documented example: [0.5, 0.3, 0.2], p = 0.6.
537        // Cumulative after token 0 (0.5) does not exceed 0.6; cumulative
538        // after token 1 (0.8) does -> token 1 (the crossing token) must be
539        // KEPT, only token 2 excluded.
540        let logits = to_logits_from_probs(&[0.5, 0.3, 0.2]);
541        let mut work = logits.clone();
542        apply_top_p(&mut work, 0.6);
543        assert!(work[0].is_finite(), "token 0 (below the threshold on its own) must be kept");
544        assert!(work[1].is_finite(), "token 1 (the crossing token) must be kept, not excluded");
545        assert!(!work[2].is_finite(), "token 2 (after the crossing point) must be excluded");
546    }
547
548    #[test]
549    fn top_p_keeps_only_the_single_token_when_it_alone_exceeds_p() {
550        let logits = to_logits_from_probs(&[0.9, 0.05, 0.05]);
551        let mut work = logits.clone();
552        apply_top_p(&mut work, 0.5);
553        assert!(work[0].is_finite());
554        assert!(!work[1].is_finite());
555        assert!(!work[2].is_finite());
556    }
557
558    #[test]
559    fn top_p_of_one_or_above_is_a_no_op() {
560        let logits = to_logits_from_probs(&[0.5, 0.3, 0.2]);
561        let mut work = logits.clone();
562        apply_top_p(&mut work, 1.0);
563        assert!(work.iter().all(|v| v.is_finite()));
564    }
565
566    /// Builds a logits row whose softmax is exactly `probs` (which must sum
567    /// to 1.0): `logit_i = ln(prob_i)` inverts softmax up to the additive
568    /// constant softmax is invariant to, so `softmax(ln(probs)) == probs`
569    /// exactly (mod floating-point rounding). This is what lets
570    /// `top_p`/`min_p` tests assert against round, hand-checkable
571    /// probabilities instead of reverse-engineering what an arbitrary
572    /// logits row softmaxes to.
573    fn to_logits_from_probs(probs: &[f32]) -> Vec<f32> {
574        probs.iter().map(|p| p.ln()).collect()
575    }
576
577    // -- apply_min_p --
578
579    #[test]
580    fn min_p_keeps_only_tokens_within_the_relative_threshold_of_the_max() {
581        // max prob = 0.7; min_p = 0.5 -> threshold = 0.35. 0.2 < 0.35 is
582        // excluded, 0.7 and (barely) nothing else survives among these.
583        let logits = to_logits_from_probs(&[0.7, 0.2, 0.1]);
584        let mut work = logits.clone();
585        apply_min_p(&mut work, 0.5);
586        assert!(work[0].is_finite());
587        assert!(!work[1].is_finite());
588        assert!(!work[2].is_finite());
589    }
590
591    #[test]
592    fn min_p_always_keeps_the_top_token() {
593        let logits = to_logits_from_probs(&[0.4, 0.35, 0.25]);
594        let mut work = logits.clone();
595        apply_min_p(&mut work, 0.99);
596        assert!(work[0].is_finite(), "the single most likely token can never be excluded by its own threshold");
597    }
598
599    #[test]
600    fn min_p_of_zero_or_below_is_a_no_op() {
601        let logits = to_logits_from_probs(&[0.7, 0.2, 0.1]);
602        let mut work = logits.clone();
603        apply_min_p(&mut work, 0.0);
604        assert!(work.iter().all(|v| v.is_finite()));
605    }
606
607    // -- softmax_ignoring_masked --
608
609    #[test]
610    fn softmax_ignoring_masked_treats_negative_infinity_as_zero_probability() {
611        let probs = softmax_ignoring_masked(&[1.0, f32::NEG_INFINITY, 1.0]);
612        assert_eq!(probs[1], 0.0);
613        assert!((probs[0] - 0.5).abs() < 1e-6);
614        assert!((probs[2] - 0.5).abs() < 1e-6);
615    }
616
617    #[test]
618    fn softmax_ignoring_masked_rows_sum_to_one() {
619        let probs = softmax_ignoring_masked(&[2.0, -1.0, 0.5, f32::NEG_INFINITY]);
620        let sum: f32 = probs.iter().sum();
621        assert!((sum - 1.0).abs() < 1e-5);
622    }
623
624    // -- StochasticSampler: the properties the task brief calls for --
625
626    /// `temperature <= 0.0` must be an *exact* drop-in for
627    /// [`GreedySampler`], not merely "usually agrees": the two code paths
628    /// must never be able to silently disagree about what "greedy" means.
629    #[test]
630    fn temperature_zero_degrades_exactly_to_greedy() {
631        let logits = [0.1, 5.0, -3.0, 2.0, 5.0]; // includes a tie, to exercise tie-breaking too.
632        let mut greedy = GreedySampler;
633        let mut stochastic = StochasticSampler::new(SamplingConfig { temperature: 0.0, ..SamplingConfig::default() });
634
635        for _ in 0..5 {
636            assert_eq!(stochastic.sample(&logits), greedy.sample(&logits));
637        }
638    }
639
640    #[test]
641    fn negative_temperature_also_degrades_to_greedy() {
642        let logits = [1.0, 9.0, 2.0];
643        let mut sampler = StochasticSampler::new(SamplingConfig { temperature: -1.0, ..SamplingConfig::default() });
644        assert_eq!(sampler.sample(&logits), greedy_argmax(&logits));
645    }
646
647    #[test]
648    fn a_fixed_seed_reproduces_a_fixed_token_sequence() {
649        let logits_sequence: Vec<Vec<f32>> =
650            (0..20).map(|i| vec![1.0 + i as f32 * 0.1, 2.0, 0.5, 3.0 - i as f32 * 0.05, 1.5]).collect();
651
652        let config = SamplingConfig { temperature: 0.8, top_k: Some(3), seed: 12345, ..SamplingConfig::default() };
653        let run = |cfg: SamplingConfig| -> Vec<u32> {
654            let mut sampler = StochasticSampler::new(cfg);
655            logits_sequence.iter().map(|l| sampler.sample(l)).collect()
656        };
657
658        let first = run(config.clone());
659        let second = run(config);
660        assert_eq!(first, second, "the same seed must reproduce the exact same token sequence");
661    }
662
663    #[test]
664    fn different_seeds_usually_produce_different_sequences() {
665        let logits_sequence: Vec<Vec<f32>> = (0..20).map(|i| vec![1.0, 2.0 + i as f32 * 0.01, 1.5, 1.8]).collect();
666        let run = |seed: u64| -> Vec<u32> {
667            let cfg = SamplingConfig { temperature: 1.0, seed, ..SamplingConfig::default() };
668            let mut sampler = StochasticSampler::new(cfg);
669            logits_sequence.iter().map(|l| sampler.sample(l)).collect()
670        };
671        assert_ne!(run(1), run(2));
672    }
673
674    #[test]
675    fn stochastic_sampler_only_ever_emits_in_range_token_ids() {
676        let vocab = 7usize;
677        let cfg = SamplingConfig {
678            temperature: 1.2,
679            top_k: Some(4),
680            top_p: Some(0.9),
681            min_p: Some(0.05),
682            repeat_penalty: 1.1,
683            seed: 999,
684            ..SamplingConfig::default()
685        };
686        let mut sampler = StochasticSampler::new(cfg);
687        let mut rng_for_logits = Rng::new(42);
688        for _ in 0..200 {
689            let logits: Vec<f32> = (0..vocab).map(|_| rng_for_logits.next_unit_f32() * 10.0 - 5.0).collect();
690            let id = sampler.sample(&logits);
691            assert!((id as usize) < vocab, "sampled id {id} out of range for vocab {vocab}");
692        }
693    }
694
695    #[test]
696    fn repetition_penalty_can_flip_which_token_greedy_decoding_picks() {
697        // Token 1 (5.0) narrowly beats token 3 (4.9) on the raw logits, so
698        // an unpenalized greedy sampler would pick token 1 forever. Once
699        // token 1 has been sampled once (entering the history window), a
700        // 2x penalty divides its logit to 2.5 -- well below token 3's
701        // untouched 4.9 -- so the very next call must switch. This is
702        // computed from a fresh division of the *raw* logit each call
703        // (see `apply_repetition_penalty`'s docs on why duplicate history
704        // entries do not compound), not a decaying value, so this test
705        // checks exactly one flip rather than a multi-call search.
706        let mut sampler = StochasticSampler::new(SamplingConfig {
707            temperature: 0.0, // greedy, so the effect of the penalty is unambiguous to check
708            repeat_penalty: 2.0,
709            repeat_last_n: 4,
710            ..SamplingConfig::default()
711        });
712        let logits = [0.1, 5.0, 0.2, 4.9];
713        assert_eq!(sampler.sample(&logits), 1, "unpenalized, token 1 has the highest raw logit");
714        assert_eq!(
715            sampler.sample(&logits),
716            3,
717            "token 1's history-penalized logit (5.0 / 2.0 = 2.5) must now lose to token 3's untouched 4.9"
718        );
719    }
720}