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.
4//!
5//! `crate::speculative` verifies draft tokens against
6//! [`sampling_distribution`] -- the exact distribution [`Sampler`]
7//! draws from for a given `SamplingParams` -- so speculation is
8//! lossless with respect to whatever sampling configuration the caller
9//! asked for, rather than only at temperature 0.
10//!
11//! No external `rand` dependency: a small xorshift64* generator (the
12//! same algorithm `Decoder::new_random_small`'s test-only `Lcg` already
13//! uses in `decoder.rs`) is enough for sampling and keeps the
14//! dependency tree the same minimal, pure-Rust shape as the rest of
15//! this crate.
16
17/// Sampling parameters for one generation request. `temperature <= 0.0`
18/// means "sample nothing, take the greedy argmax" -- the same
19/// deterministic behavior ferrox always had before this module existed.
20#[derive(Debug, Clone)]
21pub struct SamplingParams {
22    pub temperature: f32,
23    /// Nucleus sampling threshold in (0.0, 1.0]. 1.0 disables top-p
24    /// filtering (every token with nonzero probability is eligible).
25    pub top_p: f32,
26    /// Keep only the `top_k` highest-probability tokens before
27    /// sampling. 0 disables top-k filtering.
28    pub top_k: usize,
29    /// > 1.0 discourages repeating a token already in `history`; 1.0
30    /// > disables repetition penalty. Uses the standard convention
31    /// > (divide positive logits, multiply negative ones) so the penalty
32    /// > always pushes toward *less* likely, regardless of logit sign.
33    pub repetition_penalty: f32,
34    /// OpenAI-style presence penalty: subtract from logits of tokens
35    /// that already appeared in `history` (once per distinct token).
36    pub presence_penalty: f32,
37    /// OpenAI-style frequency penalty: subtract `frequency_penalty *
38    /// count` from logits for each token id seen in `history`.
39    pub frequency_penalty: f32,
40}
41
42impl Default for SamplingParams {
43    /// Greedy decoding: identical behavior to ferrox's original
44    /// argmax-only generation loop.
45    fn default() -> Self {
46        SamplingParams {
47            temperature: 0.0,
48            top_p: 1.0,
49            top_k: 0,
50            repetition_penalty: 1.0,
51            presence_penalty: 0.0,
52            frequency_penalty: 0.0,
53        }
54    }
55}
56
57/// The sampling a **checkpoint recommends for itself**, one `Option`
58/// per field so that "this model says nothing about top_p" stays
59/// distinguishable from "this model recommends top_p = 1.0". This is
60/// sglang's `sampling_defaults='model'`, ported from FreeToken
61/// `python/freetoken/utils/hf.py:92 load_generation_sampling`.
62///
63/// Every field is `None` for a checkpoint that recommends nothing,
64/// which is the overwhelming majority, and
65/// [`RecommendedSampling::resolve`] then reproduces ferrox's existing
66/// defaults exactly -- a recommendation may only fill a gap the request
67/// left, never override it.
68///
69/// Why this exists at all: reasoning checkpoints are tuned for a
70/// specific sampler (Qwen3.5 ships temperature 1.0, top_k 20, top_p
71/// 0.95) and ship those numbers with the weights. Served under a
72/// generic greedy-or-0.8 default they fall into repetition loops --
73/// fluent output that never terminates -- which reads as a broken model
74/// rather than as a serving default nobody read off the file.
75#[derive(Debug, Clone, Copy, Default, PartialEq)]
76pub struct RecommendedSampling {
77    pub temperature: Option<f32>,
78    pub top_p: Option<f32>,
79    pub top_k: Option<usize>,
80}
81
82/// The sampling fields **one request** actually specified. `None` means
83/// the request said nothing about that field, so the checkpoint's
84/// recommendation (and then the framework default) may speak for it.
85///
86/// Collapsing this to a plain [`SamplingParams`] at the wire boundary
87/// -- `temperature: req.temperature.unwrap_or(0.0)` -- is what destroys
88/// the distinction: a request that omitted `temperature` becomes
89/// indistinguishable from one that explicitly asked for greedy, and no
90/// recommendation can ever apply.
91#[derive(Debug, Clone, Copy, Default, PartialEq)]
92pub struct RequestedSampling {
93    pub temperature: Option<f32>,
94    pub top_p: Option<f32>,
95    pub top_k: Option<usize>,
96}
97
98impl RecommendedSampling {
99    /// True when the checkpoint recommended nothing at all, i.e.
100    /// [`Self::resolve`] is guaranteed to return the framework defaults
101    /// for any request. Useful for telling an operator whether
102    /// "model defaults" had anything to act on.
103    pub fn is_empty(&self) -> bool {
104        *self == RecommendedSampling::default()
105    }
106
107    /// Precedence, exactly as FreeToken's `resolve_sampling.pick`
108    /// (`python/freetoken/server/generation.py:170`) applies it: the
109    /// **request's** own value, else the **checkpoint's**
110    /// recommendation, else the **framework** default carried by
111    /// `framework` (ferrox's `SamplingParams::default()` unless a caller
112    /// has its own).
113    ///
114    /// The penalty fields are taken from `framework` untouched: nothing
115    /// in the reference reads a recommended penalty, and inventing one
116    /// here would be this function changing generation on its own.
117    ///
118    /// Getting the order wrong in either direction is a silent
119    /// behaviour change: recommendation-over-request makes a client's
120    /// explicit `temperature: 0` unreachable on a model that recommends
121    /// 1.0, and framework-over-recommendation is the greedy repetition
122    /// loop this whole path exists to avoid.
123    pub fn resolve(
124        &self,
125        requested: RequestedSampling,
126        framework: SamplingParams,
127    ) -> SamplingParams {
128        SamplingParams {
129            temperature: requested
130                .temperature
131                .or(self.temperature)
132                .unwrap_or(framework.temperature),
133            top_p: requested.top_p.or(self.top_p).unwrap_or(framework.top_p),
134            top_k: requested.top_k.or(self.top_k).unwrap_or(framework.top_k),
135            ..framework
136        }
137    }
138
139    /// The recommendation in a HuggingFace-style `generation_config.json`
140    /// body.
141    ///
142    /// Two rules, both from the reference
143    /// (`hf.py:92 load_generation_sampling`):
144    ///
145    /// * `do_sample: false` means the checkpoint recommends **greedy**,
146    ///   which is returned as `temperature = 0` and *nothing else* --
147    ///   the top_k/top_p in such a file describe a sampler the model
148    ///   asks not to be used.
149    /// * otherwise only the keys **actually present** are returned. An
150    ///   absent key stays `None`; filling it with a house default (the
151    ///   naive reading, and what HF's own `GenerationConfig` object does
152    ///   for you) would turn silence into a recommendation and let a
153    ///   file that says only `temperature: 0.6` also pin top_p to 1.0,
154    ///   overriding the server's own default for a value the checkpoint
155    ///   never expressed.
156    ///
157    /// A file that does not parse, or is not a JSON object, recommends
158    /// nothing -- a malformed sidecar must not be able to change how a
159    /// model is sampled.
160    pub fn from_generation_config(json: &str) -> Self {
161        let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(json)
162        else {
163            return RecommendedSampling::default();
164        };
165        if map.get("do_sample").and_then(|v| v.as_bool()) == Some(false) {
166            return RecommendedSampling {
167                temperature: Some(0.0),
168                ..RecommendedSampling::default()
169            };
170        }
171        RecommendedSampling {
172            temperature: map
173                .get("temperature")
174                .and_then(|v| v.as_f64())
175                .map(|v| v as f32),
176            top_p: map.get("top_p").and_then(|v| v.as_f64()).map(|v| v as f32),
177            top_k: map
178                .get("top_k")
179                .and_then(|v| v.as_u64())
180                .map(|v| v as usize),
181        }
182    }
183
184    /// [`Self::from_generation_config`] for the `generation_config.json`
185    /// beside a checkpoint's weights (an HF-format model directory).
186    ///
187    /// A directory with no such file recommends nothing, exactly like a
188    /// GGUF with no `general.sampling.*` keys: the absence of a
189    /// recommendation is the normal case and must never be an error that
190    /// stops a model from loading.
191    pub fn from_model_dir(dir: &std::path::Path) -> Self {
192        match std::fs::read_to_string(dir.join("generation_config.json")) {
193            Ok(text) => Self::from_generation_config(&text),
194            Err(_) => RecommendedSampling::default(),
195        }
196    }
197}
198
199/// Zeroes out logits a caller wants to forbid, in place, before the
200/// sampler looks at them. Used by JSON-object mode to keep generation
201/// inside the grammar.
202pub type LogitMask<'a> = &'a mut dyn FnMut(&mut [f32]);
203
204/// A small, seedable xorshift64* generator. Not cryptographically
205/// secure -- sampling doesn't need that -- but reproducible given a
206/// seed, which greedy argmax already was for free.
207pub struct Sampler {
208    state: u64,
209}
210
211impl Sampler {
212    pub fn new(seed: u64) -> Self {
213        // xorshift64* requires a nonzero seed.
214        Sampler {
215            state: if seed == 0 { 0x9E3779B97F4A7C15 } else { seed },
216        }
217    }
218
219    fn next_u64(&mut self) -> u64 {
220        self.state ^= self.state << 13;
221        self.state ^= self.state >> 7;
222        self.state ^= self.state << 17;
223        // The `*` in xorshift64*. Without it this is plain xorshift64,
224        // whose state IS its output, and a small seed's first output is
225        // therefore still small: for every seed below ~4000 the first
226        // draw landed in the bottom eighth of [0, 1), so a request that
227        // asked for `seed: 42` always got its first token from the
228        // bottom of the CDF. The multiply is what decorrelates the
229        // output from a low-entropy state; see
230        // `low_seeds_do_not_bias_the_first_draw`.
231        self.state.wrapping_mul(0x2545F491_4F6CDD1D)
232    }
233
234    /// Uniform float in [0.0, 1.0).
235    fn next_f32(&mut self) -> f32 {
236        (self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
237    }
238
239    /// Samples one token id from `logits`, given `params` and the
240    /// already-generated `history` (for repetition penalty). Falls back
241    /// to plain greedy argmax when `params.temperature <= 0.0`.
242    ///
243    /// A length-1 `logits` vector is treated as a precomputed greedy token
244    /// id (`logits[0] as usize`) — used by the Metal dense-stack path that
245    /// returns GPU argmax instead of downloading the full vocab.
246    pub fn sample(&mut self, logits: &[f32], params: &SamplingParams, history: &[usize]) -> usize {
247        self.sample_with_mask(logits, params, history, None)
248    }
249
250    /// Like [`Self::sample`], but optionally zeroes disallowed logits via
251    /// `mask` before argmax / nucleus sampling (used for JSON-object mode).
252    pub fn sample_with_mask(
253        &mut self,
254        logits: &[f32],
255        params: &SamplingParams,
256        history: &[usize],
257        mut mask: Option<LogitMask<'_>>,
258    ) -> usize {
259        if params.temperature <= 0.0 && mask.is_none() {
260            if logits.len() == 1 {
261                return logits[0] as usize;
262            }
263            let mut scores = logits.to_vec();
264            apply_history_penalties(&mut scores, params, history);
265            return argmax(&scores);
266        }
267
268        let mut scores: Vec<f32> = logits.to_vec();
269        apply_history_penalties(&mut scores, params, history);
270
271        if let Some(m) = mask.as_mut() {
272            m(&mut scores);
273        }
274
275        if params.temperature <= 0.0 {
276            if scores.len() == 1 {
277                return scores[0] as usize;
278            }
279            return argmax(&scores);
280        }
281
282        let probs = filtered_distribution(scores, logits, params);
283        self.sample_from(&probs)
284    }
285
286    /// A uniform draw in `[0.0, 1.0)`.
287    ///
288    /// Exposed because speculative decoding's accept test is a coin
289    /// flip against `p_target(x) / p_draft(x)` rather than a draw from
290    /// a distribution, and it must come off the same seeded stream as
291    /// every other draw in the run or a "reproducible given a seed"
292    /// generation stops being reproducible.
293    pub fn uniform(&mut self) -> f32 {
294        self.next_f32()
295    }
296
297    /// Draws one index from an already-normalised distribution.
298    ///
299    /// Split out of [`Self::sample_with_mask`] so speculative decoding
300    /// can sample from a distribution it had to compute anyway (the
301    /// rejection rule needs `p_target` itself, not just a draw from it)
302    /// and still go through *exactly* the same draw as ordinary
303    /// sampling. Two separate copies of this loop would be two chances
304    /// to be subtly non-lossless.
305    pub fn sample_from(&mut self, probs: &[f32]) -> usize {
306        let draw = self.next_f32();
307        let mut cumulative = 0.0f32;
308        for (i, &p) in probs.iter().enumerate() {
309            cumulative += p;
310            if draw < cumulative {
311                return i;
312            }
313        }
314        // Floating-point rounding may leave `draw` fractionally above
315        // the final cumulative sum; the last nonzero-probability token
316        // is the correct fallback, not index 0.
317        probs
318            .iter()
319            .enumerate()
320            .rev()
321            .find(|&(_, &p)| p > 0.0)
322            .map(|(i, _)| i)
323            .unwrap_or(0)
324    }
325}
326
327/// The **exact** distribution [`Sampler::sample`] draws from for these
328/// logits, params and history: penalties applied, temperature divided
329/// in, top-k and top-p filtered, renormalised to sum to 1.
330///
331/// This is what makes lossless speculative verification possible. The
332/// speculative-sampling rejection rule compares `p_target(x)` against
333/// the draft's `q(x)`, and "the target's probability" is meaningless
334/// unless it is the probability the *configured sampler* would actually
335/// have used -- a rule that compared against the raw softmax while the
336/// server sampled with `top_p = 0.9` would be lossless with respect to
337/// a model nobody is running.
338///
339/// Greedy (`temperature <= 0.0`) is a distribution too: the point mass
340/// on the argmax. Returning it as one rather than as a special case is
341/// why the same verification code is correct at every temperature.
342pub fn sampling_distribution(
343    logits: &[f32],
344    params: &SamplingParams,
345    history: &[usize],
346) -> Vec<f32> {
347    let mut scores = logits.to_vec();
348    apply_history_penalties(&mut scores, params, history);
349    if params.temperature <= 0.0 {
350        let mut probs = vec![0.0f32; scores.len()];
351        if let Some(p) = probs.get_mut(argmax(&scores)) {
352            *p = 1.0;
353        }
354        return probs;
355    }
356    filtered_distribution(scores, logits, params)
357}
358
359/// Shared tail of [`Sampler::sample_with_mask`] and
360/// [`sampling_distribution`]: divide the already-penalised `scores` by
361/// the temperature, softmax, apply top-k and top-p, and renormalise.
362/// `raw_logits` is only consulted for the degenerate
363/// everything-filtered-to-zero fallback.
364///
365/// Both callers go through here rather than each doing their own
366/// temperature-then-filter, because a difference between the two is
367/// exactly the kind of silent non-losslessness speculative
368/// verification is supposed to rule out.
369fn filtered_distribution(
370    mut scores: Vec<f32>,
371    raw_logits: &[f32],
372    params: &SamplingParams,
373) -> Vec<f32> {
374    for s in scores.iter_mut() {
375        *s /= params.temperature;
376    }
377    let mut probs = softmax(&scores);
378
379    if params.top_k > 0 && params.top_k < probs.len() {
380        let mut idx: Vec<usize> = (0..probs.len()).collect();
381        idx.sort_unstable_by(|&a, &b| probs[b].partial_cmp(&probs[a]).unwrap());
382        for &i in idx.iter().skip(params.top_k) {
383            probs[i] = 0.0;
384        }
385    }
386
387    if params.top_p < 1.0 {
388        let mut idx: Vec<usize> = (0..probs.len()).collect();
389        idx.sort_unstable_by(|&a, &b| probs[b].partial_cmp(&probs[a]).unwrap());
390        let mut cumulative = 0.0f32;
391        let mut cutoff = idx.len();
392        for (rank, &i) in idx.iter().enumerate() {
393            cumulative += probs[i];
394            if cumulative >= params.top_p {
395                cutoff = rank + 1;
396                break;
397            }
398        }
399        for &i in idx.iter().skip(cutoff) {
400            probs[i] = 0.0;
401        }
402    }
403
404    let total: f32 = probs.iter().sum();
405    if total <= 0.0 {
406        // Every candidate got filtered to zero (degenerate params);
407        // fall back to greedy rather than sampling from nothing.
408        let mut point = vec![0.0f32; probs.len()];
409        if let Some(p) = point.get_mut(argmax(raw_logits)) {
410            *p = 1.0;
411        }
412        return point;
413    }
414    for p in probs.iter_mut() {
415        *p /= total;
416    }
417    probs
418}
419
420fn apply_history_penalties(scores: &mut [f32], params: &SamplingParams, history: &[usize]) {
421    if params.repetition_penalty != 1.0 {
422        for &tok in history {
423            if let Some(s) = scores.get_mut(tok) {
424                *s = if *s > 0.0 {
425                    *s / params.repetition_penalty
426                } else {
427                    *s * params.repetition_penalty
428                };
429            }
430        }
431    }
432    if params.presence_penalty != 0.0 || params.frequency_penalty != 0.0 {
433        let mut counts = std::collections::HashMap::<usize, usize>::new();
434        for &tok in history {
435            *counts.entry(tok).or_insert(0) += 1;
436        }
437        for (tok, count) in counts {
438            if let Some(s) = scores.get_mut(tok) {
439                *s -= params.frequency_penalty * count as f32;
440                *s -= params.presence_penalty;
441            }
442        }
443    }
444}
445
446fn argmax(logits: &[f32]) -> usize {
447    logits
448        .iter()
449        .enumerate()
450        .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
451        .map(|(i, _)| i)
452        .unwrap_or(0)
453}
454
455fn softmax(logits: &[f32]) -> Vec<f32> {
456    let max = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
457    let exps: Vec<f32> = logits.iter().map(|&l| (l - max).exp()).collect();
458    let sum: f32 = exps.iter().sum();
459    if sum <= 0.0 {
460        vec![1.0 / logits.len().max(1) as f32; logits.len()]
461    } else {
462        exps.into_iter().map(|e| e / sum).collect()
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    #[test]
471    fn temperature_zero_accepts_precomputed_argmax_singleton() {
472        let mut sampler = Sampler::new(1);
473        let params = SamplingParams::default();
474        assert_eq!(sampler.sample(&[42.0], &params, &[]), 42);
475        // Non-greedy must not treat a singleton as a token id.
476        let sampled = SamplingParams {
477            temperature: 0.8,
478            ..SamplingParams::default()
479        };
480        // Softmax of a single logit → only token 0 is eligible.
481        assert_eq!(sampler.sample(&[42.0], &sampled, &[]), 0);
482    }
483
484    #[test]
485    fn temperature_zero_is_deterministic_greedy_argmax() {
486        let logits = vec![0.1, 0.9, 0.3, -0.2];
487        let params = SamplingParams::default();
488        let mut sampler = Sampler::new(42);
489        assert_eq!(sampler.sample(&logits, &params, &[]), 1);
490        // Must be deterministic regardless of RNG state advancing.
491        assert_eq!(sampler.sample(&logits, &params, &[]), 1);
492    }
493
494    #[test]
495    fn high_temperature_can_pick_a_non_argmax_token_over_many_draws() {
496        let logits = vec![1.0, 1.0, 1.0, 1.0];
497        let params = SamplingParams {
498            temperature: 1.0,
499            ..SamplingParams::default()
500        };
501        let mut sampler = Sampler::new(7);
502        let mut seen = std::collections::HashSet::new();
503        for _ in 0..200 {
504            seen.insert(sampler.sample(&logits, &params, &[]));
505        }
506        assert!(
507            seen.len() > 1,
508            "uniform logits at temperature=1.0 must produce more than one distinct token across 200 draws"
509        );
510    }
511
512    #[test]
513    fn top_k_one_is_equivalent_to_greedy() {
514        let logits = vec![0.1, 0.9, 0.3, -0.2];
515        let params = SamplingParams {
516            temperature: 1.0,
517            top_k: 1,
518            ..SamplingParams::default()
519        };
520        let mut sampler = Sampler::new(123);
521        for _ in 0..20 {
522            assert_eq!(sampler.sample(&logits, &params, &[]), 1);
523        }
524    }
525
526    #[test]
527    fn top_p_near_zero_is_equivalent_to_greedy() {
528        let logits = vec![0.1, 5.0, 0.3, -0.2];
529        let params = SamplingParams {
530            temperature: 1.0,
531            top_p: 0.001,
532            ..SamplingParams::default()
533        };
534        let mut sampler = Sampler::new(9);
535        for _ in 0..20 {
536            assert_eq!(sampler.sample(&logits, &params, &[]), 1);
537        }
538    }
539
540    #[test]
541    fn presence_and_frequency_penalties_reduce_seen_token_logits() {
542        let logits = vec![0.0, 5.0, 0.0];
543        let params = SamplingParams {
544            temperature: 1.0,
545            presence_penalty: 10.0,
546            frequency_penalty: 0.0,
547            ..SamplingParams::default()
548        };
549        let mut sampler = Sampler::new(1);
550        let mut counts = [0usize; 3];
551        for _ in 0..500 {
552            counts[sampler.sample(&logits, &params, &[1])] += 1;
553        }
554        assert!(
555            counts[1] < 250,
556            "presence_penalty should discourage token 1; counts={counts:?}"
557        );
558
559        let params = SamplingParams {
560            temperature: 1.0,
561            presence_penalty: 0.0,
562            frequency_penalty: 10.0,
563            ..SamplingParams::default()
564        };
565        let mut sampler = Sampler::new(2);
566        counts = [0; 3];
567        for _ in 0..500 {
568            counts[sampler.sample(&logits, &params, &[1, 1, 1])] += 1;
569        }
570        assert!(
571            counts[1] < 250,
572            "frequency_penalty should discourage repeated token 1; counts={counts:?}"
573        );
574    }
575
576    #[test]
577    fn repetition_penalty_reduces_probability_of_recently_seen_token() {
578        let logits = vec![0.0, 5.0, 0.0];
579        let params = SamplingParams {
580            temperature: 1.0,
581            repetition_penalty: 1000.0,
582            ..SamplingParams::default()
583        };
584        let mut sampler = Sampler::new(3);
585        let mut counts = [0usize; 3];
586        for _ in 0..500 {
587            counts[sampler.sample(&logits, &params, &[1])] += 1;
588        }
589        assert!(
590            counts[1] < 250,
591            "heavily penalizing token 1 (already in history) should make it far less likely than its raw logit alone would suggest; got counts={counts:?}"
592        );
593    }
594
595    #[test]
596    fn low_seeds_do_not_bias_the_first_draw() {
597        // Every generation seeds a fresh `Sampler` (the server does it
598        // per request, from the caller's `seed`), so the FIRST draw off
599        // a freshly seeded generator is the one users actually see.
600        // Plain xorshift64 returns its own state, so seeds 1..4000 all
601        // produced a first draw in the bottom eighth of [0, 1) -- the
602        // first sampled token of every seeded request came off the
603        // bottom of the CDF.
604        let vocab = 8;
605        let logits = vec![0.0f32; vocab];
606        let params = SamplingParams {
607            temperature: 1.0,
608            ..SamplingParams::default()
609        };
610        let seeds = 4_000u64;
611        let mut counts = vec![0usize; vocab];
612        for seed in 1..=seeds {
613            counts[Sampler::new(seed).sample(&logits, &params, &[])] += 1;
614        }
615        let expected = seeds as f64 / vocab as f64;
616        for (token, &c) in counts.iter().enumerate() {
617            assert!(
618                (c as f64 - expected).abs() < expected * 0.25,
619                "uniform logits: token {token} came up {c} times across {seeds} seeds, \
620                 expected about {expected:.0} (counts={counts:?})"
621            );
622        }
623    }
624
625    #[test]
626    fn the_published_distribution_is_the_one_sample_actually_draws_from() {
627        // `sampling_distribution` is load-bearing for lossless
628        // speculative verification: if it disagreed with what `sample`
629        // draws from, every accept/reject decision would be measured
630        // against the wrong target. Check them against each other
631        // empirically, with filters on so the two code paths have
632        // something to disagree about.
633        let logits = vec![0.4, 2.0, -1.0, 1.2, 0.9, -0.3];
634        let params = SamplingParams {
635            temperature: 0.8,
636            top_p: 0.9,
637            top_k: 4,
638            repetition_penalty: 1.3,
639            ..SamplingParams::default()
640        };
641        let history = [1usize, 4];
642        let claimed = sampling_distribution(&logits, &params, &history);
643        assert!((claimed.iter().sum::<f32>() - 1.0).abs() < 1e-5);
644
645        let draws = 100_000;
646        let mut counts = vec![0usize; logits.len()];
647        let mut sampler = Sampler::new(0xC0FFEE);
648        for _ in 0..draws {
649            counts[sampler.sample(&logits, &params, &history)] += 1;
650        }
651        for (i, &c) in counts.iter().enumerate() {
652            let empirical = c as f64 / draws as f64;
653            assert!(
654                (empirical - claimed[i] as f64).abs() < 0.01,
655                "token {i}: sample() draws it {empirical:.4} of the time but \
656                 sampling_distribution claims {:.4}",
657                claimed[i]
658            );
659        }
660    }
661
662    #[test]
663    fn greedy_is_published_as_a_point_mass_not_a_special_case() {
664        let logits = vec![0.1, 0.9, 0.3, -0.2];
665        let probs = sampling_distribution(&logits, &SamplingParams::default(), &[]);
666        assert_eq!(probs, vec![0.0, 1.0, 0.0, 0.0]);
667        // Penalties still apply at temperature 0, so the point mass
668        // moves with them.
669        let penalized = sampling_distribution(
670            &logits,
671            &SamplingParams {
672                repetition_penalty: 100.0,
673                ..SamplingParams::default()
674            },
675            &[1],
676        );
677        assert_eq!(penalized[1], 0.0);
678        assert_eq!(penalized.iter().sum::<f32>(), 1.0);
679    }
680
681    #[test]
682    fn degenerate_all_zero_probability_falls_back_to_greedy() {
683        // top_k=1 combined with a top_p that would exclude even that
684        // one surviving token is a contradictory/degenerate
685        // configuration; must not panic or sample index 0 blindly.
686        let logits = vec![0.1, 0.9, 0.3, -0.2];
687        let params = SamplingParams {
688            temperature: 1.0,
689            top_k: 1,
690            top_p: 1.0,
691            ..SamplingParams::default()
692        };
693        let mut sampler = Sampler::new(1);
694        assert_eq!(sampler.sample(&logits, &params, &[]), 1);
695    }
696
697    /// Only the keys the file actually carries become a recommendation.
698    ///
699    /// **This test fails if an absent key is filled with a house
700    /// default** (the naive reading, and what HF's own
701    /// `GenerationConfig` object does): `top_p` and `top_k` would come
702    /// back as `Some(1.0)` / `Some(0)` and would then override whatever
703    /// the server itself defaults to, for values this checkpoint never
704    /// expressed.
705    #[test]
706    fn an_absent_generation_config_key_stays_absent_rather_than_taking_a_default() {
707        let recommended = RecommendedSampling::from_generation_config(r#"{"temperature": 0.6}"#);
708        assert_eq!(recommended.temperature, Some(0.6));
709        assert_eq!(recommended.top_p, None, "top_p was not in the file");
710        assert_eq!(recommended.top_k, None, "top_k was not in the file");
711        // An explicit JSON null is silence too (the reference's
712        // `if val is not None`).
713        let nulled = RecommendedSampling::from_generation_config(r#"{"top_p": null}"#);
714        assert_eq!(nulled, RecommendedSampling::default());
715    }
716
717    /// A reasoning checkpoint's full recommendation survives intact --
718    /// the case the whole path exists for (Qwen3.5: temp 1.0, top_k 20,
719    /// top_p 0.95).
720    #[test]
721    fn every_generation_config_key_present_is_recommended() {
722        let recommended = RecommendedSampling::from_generation_config(
723            r#"{"do_sample": true, "temperature": 1.0, "top_k": 20, "top_p": 0.95}"#,
724        );
725        assert_eq!(
726            recommended,
727            RecommendedSampling {
728                temperature: Some(1.0),
729                top_p: Some(0.95),
730                top_k: Some(20),
731            }
732        );
733    }
734
735    /// `do_sample: false` recommends greedy, expressed as temperature 0
736    /// and *nothing else*: the top_k/top_p such a file also carries
737    /// describe a sampler it is asking not to be used, so returning them
738    /// would filter a distribution the model wants collapsed to its
739    /// argmax.
740    #[test]
741    fn do_sample_false_recommends_greedy_and_no_other_field() {
742        let recommended = RecommendedSampling::from_generation_config(
743            r#"{"do_sample": false, "temperature": 0.7, "top_k": 50, "top_p": 0.9}"#,
744        );
745        assert_eq!(recommended.temperature, Some(0.0));
746        assert_eq!(recommended.top_p, None);
747        assert_eq!(recommended.top_k, None);
748    }
749
750    /// A sidecar that does not parse must not be able to change how the
751    /// model is sampled.
752    #[test]
753    fn a_malformed_generation_config_recommends_nothing() {
754        for text in ["", "not json", "[1, 2, 3]", "null"] {
755            assert!(
756                RecommendedSampling::from_generation_config(text).is_empty(),
757                "{text:?} must recommend nothing"
758            );
759        }
760    }
761
762    /// Precedence: the request wins over the checkpoint, and the
763    /// checkpoint only fills what the request left unset. An explicit
764    /// `temperature: 0` from a client must stay reachable on a model
765    /// that recommends 1.0.
766    #[test]
767    fn a_request_outranks_the_recommendation_which_outranks_the_framework_default() {
768        let recommended = RecommendedSampling {
769            temperature: Some(1.0),
770            top_p: Some(0.95),
771            top_k: Some(20),
772        };
773        let resolved = recommended.resolve(
774            RequestedSampling {
775                temperature: Some(0.0),
776                ..RequestedSampling::default()
777            },
778            SamplingParams::default(),
779        );
780        assert_eq!(resolved.temperature, 0.0, "the request asked for greedy");
781        assert_eq!(resolved.top_p, 0.95, "the request said nothing about top_p");
782        assert_eq!(resolved.top_k, 20, "the request said nothing about top_k");
783        // Penalties are never recommended, only carried through.
784        assert_eq!(resolved.repetition_penalty, 1.0);
785    }
786
787    /// A checkpoint that recommends nothing must leave ferrox's existing
788    /// behaviour bit-identical: greedy, unfiltered, exactly
789    /// `SamplingParams::default()`.
790    #[test]
791    fn a_checkpoint_that_recommends_nothing_leaves_the_framework_defaults_alone() {
792        let resolved = RecommendedSampling::default()
793            .resolve(RequestedSampling::default(), SamplingParams::default());
794        let default = SamplingParams::default();
795        assert_eq!(resolved.temperature, default.temperature);
796        assert_eq!(resolved.top_p, default.top_p);
797        assert_eq!(resolved.top_k, default.top_k);
798    }
799
800    /// A model directory with no `generation_config.json` recommends
801    /// nothing rather than failing: the absence of a recommendation is
802    /// the normal case for most checkpoints.
803    #[test]
804    fn a_model_directory_without_a_generation_config_recommends_nothing() {
805        let dir = std::env::temp_dir().join(format!(
806            "ferrox_test_no_generation_config_{}",
807            std::process::id()
808        ));
809        std::fs::create_dir_all(&dir).unwrap();
810        assert!(RecommendedSampling::from_model_dir(&dir).is_empty());
811        std::fs::remove_dir_all(&dir).ok();
812    }
813
814    /// The sidecar is read from the directory beside the weights, the
815    /// same place HF's `GenerationConfig.from_pretrained` looks.
816    #[test]
817    fn a_model_directory_generation_config_is_read_from_beside_the_weights() {
818        let dir = std::env::temp_dir().join(format!(
819            "ferrox_test_generation_config_dir_{}",
820            std::process::id()
821        ));
822        std::fs::create_dir_all(&dir).unwrap();
823        std::fs::write(
824            dir.join("generation_config.json"),
825            r#"{"temperature": 0.6, "top_p": 0.95}"#,
826        )
827        .unwrap();
828        let recommended = RecommendedSampling::from_model_dir(&dir);
829        std::fs::remove_dir_all(&dir).ok();
830        assert_eq!(recommended.temperature, Some(0.6));
831        assert_eq!(recommended.top_p, Some(0.95));
832        assert_eq!(recommended.top_k, None);
833    }
834}