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
17use crate::sampler_chain::Candidates;
18
19/// Sampling parameters for one generation request. `temperature <= 0.0`
20/// means "sample nothing, take the greedy argmax" -- the same
21/// deterministic behavior ferrox always had before this module existed.
22#[derive(Debug, Clone)]
23pub struct SamplingParams {
24    pub temperature: f32,
25    /// Nucleus sampling threshold in (0.0, 1.0]. 1.0 disables top-p
26    /// filtering (every token with nonzero probability is eligible).
27    pub top_p: f32,
28    /// Keep only candidates at least `min_p` times as likely as the most
29    /// likely one. `0.0` disables it; llama.cpp's `--min-p`, whose
30    /// default is **0.05** (`common/common.h:231`) rather than off.
31    ///
32    /// That default is why this is a parity item and not a feature:
33    /// llama.cpp truncates with min-p on every run nobody configured,
34    /// so without it ferrox could not reproduce llama.cpp's *own*
35    /// out-of-the-box output for any prompt.
36    ///
37    /// The struct default here stays `0.0` (disabled) for the same
38    /// reason `temperature` defaults to greedy: `SamplingParams::default`
39    /// is ferrox's "do nothing the caller did not ask for" baseline, and
40    /// llama.cpp's CLI numbers live on the CLI flags.
41    pub min_p: f32,
42    /// Keep only the `top_k` highest-probability tokens before
43    /// sampling. 0 disables top-k filtering.
44    pub top_k: usize,
45    /// > 1.0 discourages repeating a token already in `history`; 1.0
46    /// > disables repetition penalty. Uses the standard convention
47    /// > (divide positive logits, multiply negative ones) so the penalty
48    /// > always pushes toward *less* likely, regardless of logit sign.
49    pub repetition_penalty: f32,
50    /// How many of the most recent tokens the penalties look at, as
51    /// llama.cpp's `penalty_last_n` (`common/common.h:238`, default 64).
52    ///
53    /// `0` disables the penalties entirely. ferrox had no window at all
54    /// and scanned the WHOLE history, so on a long generation it
55    /// penalised a steadily growing set of tokens where llama.cpp
56    /// penalises the last 64 -- the divergence grew with output length,
57    /// which is exactly when a repetition penalty matters most.
58    pub penalty_last_n: usize,
59    /// OpenAI-style presence penalty: subtract from logits of tokens
60    /// that already appeared in `history` (once per distinct token).
61    pub presence_penalty: f32,
62    /// OpenAI-style frequency penalty: subtract `frequency_penalty *
63    /// count` from logits for each token id seen in `history`.
64    pub frequency_penalty: f32,
65}
66
67impl Default for SamplingParams {
68    /// Greedy decoding: identical behavior to ferrox's original
69    /// argmax-only generation loop.
70    fn default() -> Self {
71        SamplingParams {
72            temperature: 0.0,
73            top_p: 1.0,
74            min_p: 0.0,
75            top_k: 0,
76            repetition_penalty: 1.0,
77            penalty_last_n: 64,
78            presence_penalty: 0.0,
79            frequency_penalty: 0.0,
80        }
81    }
82}
83
84/// The sampling a **checkpoint recommends for itself**, one `Option`
85/// per field so that "this model says nothing about top_p" stays
86/// distinguishable from "this model recommends top_p = 1.0". This is
87/// sglang's `sampling_defaults='model'`, ported from FreeToken
88/// `python/freetoken/utils/hf.py:92 load_generation_sampling`.
89///
90/// Every field is `None` for a checkpoint that recommends nothing,
91/// which is the overwhelming majority, and
92/// [`RecommendedSampling::resolve`] then reproduces ferrox's existing
93/// defaults exactly -- a recommendation may only fill a gap the request
94/// left, never override it.
95///
96/// Why this exists at all: reasoning checkpoints are tuned for a
97/// specific sampler (Qwen3.5 ships temperature 1.0, top_k 20, top_p
98/// 0.95) and ship those numbers with the weights. Served under a
99/// generic greedy-or-0.8 default they fall into repetition loops --
100/// fluent output that never terminates -- which reads as a broken model
101/// rather than as a serving default nobody read off the file.
102#[derive(Debug, Clone, Copy, Default, PartialEq)]
103pub struct RecommendedSampling {
104    pub temperature: Option<f32>,
105    pub top_p: Option<f32>,
106    pub top_k: Option<usize>,
107}
108
109/// The sampling fields **one request** actually specified. `None` means
110/// the request said nothing about that field, so the checkpoint's
111/// recommendation (and then the framework default) may speak for it.
112///
113/// Collapsing this to a plain [`SamplingParams`] at the wire boundary
114/// -- `temperature: req.temperature.unwrap_or(0.0)` -- is what destroys
115/// the distinction: a request that omitted `temperature` becomes
116/// indistinguishable from one that explicitly asked for greedy, and no
117/// recommendation can ever apply.
118#[derive(Debug, Clone, Copy, Default, PartialEq)]
119pub struct RequestedSampling {
120    pub temperature: Option<f32>,
121    pub top_p: Option<f32>,
122    pub top_k: Option<usize>,
123}
124
125impl RecommendedSampling {
126    /// True when the checkpoint recommended nothing at all, i.e.
127    /// [`Self::resolve`] is guaranteed to return the framework defaults
128    /// for any request. Useful for telling an operator whether
129    /// "model defaults" had anything to act on.
130    pub fn is_empty(&self) -> bool {
131        *self == RecommendedSampling::default()
132    }
133
134    /// Precedence, exactly as FreeToken's `resolve_sampling.pick`
135    /// (`python/freetoken/server/generation.py:170`) applies it: the
136    /// **request's** own value, else the **checkpoint's**
137    /// recommendation, else the **framework** default carried by
138    /// `framework` (ferrox's `SamplingParams::default()` unless a caller
139    /// has its own).
140    ///
141    /// The penalty fields are taken from `framework` untouched: nothing
142    /// in the reference reads a recommended penalty, and inventing one
143    /// here would be this function changing generation on its own.
144    ///
145    /// Getting the order wrong in either direction is a silent
146    /// behaviour change: recommendation-over-request makes a client's
147    /// explicit `temperature: 0` unreachable on a model that recommends
148    /// 1.0, and framework-over-recommendation is the greedy repetition
149    /// loop this whole path exists to avoid.
150    pub fn resolve(
151        &self,
152        requested: RequestedSampling,
153        framework: SamplingParams,
154    ) -> SamplingParams {
155        SamplingParams {
156            temperature: requested
157                .temperature
158                .or(self.temperature)
159                .unwrap_or(framework.temperature),
160            top_p: requested.top_p.or(self.top_p).unwrap_or(framework.top_p),
161            top_k: requested.top_k.or(self.top_k).unwrap_or(framework.top_k),
162            ..framework
163        }
164    }
165
166    /// The recommendation in a HuggingFace-style `generation_config.json`
167    /// body.
168    ///
169    /// Two rules, both from the reference
170    /// (`hf.py:92 load_generation_sampling`):
171    ///
172    /// * `do_sample: false` means the checkpoint recommends **greedy**,
173    ///   which is returned as `temperature = 0` and *nothing else* --
174    ///   the top_k/top_p in such a file describe a sampler the model
175    ///   asks not to be used.
176    /// * otherwise only the keys **actually present** are returned. An
177    ///   absent key stays `None`; filling it with a house default (the
178    ///   naive reading, and what HF's own `GenerationConfig` object does
179    ///   for you) would turn silence into a recommendation and let a
180    ///   file that says only `temperature: 0.6` also pin top_p to 1.0,
181    ///   overriding the server's own default for a value the checkpoint
182    ///   never expressed.
183    ///
184    /// A file that does not parse, or is not a JSON object, recommends
185    /// nothing -- a malformed sidecar must not be able to change how a
186    /// model is sampled.
187    pub fn from_generation_config(json: &str) -> Self {
188        let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(json)
189        else {
190            return RecommendedSampling::default();
191        };
192        if map.get("do_sample").and_then(|v| v.as_bool()) == Some(false) {
193            return RecommendedSampling {
194                temperature: Some(0.0),
195                ..RecommendedSampling::default()
196            };
197        }
198        RecommendedSampling {
199            temperature: map
200                .get("temperature")
201                .and_then(|v| v.as_f64())
202                .map(|v| v as f32),
203            top_p: map.get("top_p").and_then(|v| v.as_f64()).map(|v| v as f32),
204            top_k: map
205                .get("top_k")
206                .and_then(|v| v.as_u64())
207                .map(|v| v as usize),
208        }
209    }
210
211    /// [`Self::from_generation_config`] for the `generation_config.json`
212    /// beside a checkpoint's weights (an HF-format model directory).
213    ///
214    /// A directory with no such file recommends nothing, exactly like a
215    /// GGUF with no `general.sampling.*` keys: the absence of a
216    /// recommendation is the normal case and must never be an error that
217    /// stops a model from loading.
218    pub fn from_model_dir(dir: &std::path::Path) -> Self {
219        match std::fs::read_to_string(dir.join("generation_config.json")) {
220            Ok(text) => Self::from_generation_config(&text),
221            Err(_) => RecommendedSampling::default(),
222        }
223    }
224}
225
226/// Sets logits a caller wants to forbid to `-inf`, in place, before the
227/// sampler looks at them.
228///
229/// Two callers today, and they COMPOSE rather than exclude each other --
230/// a masked logit stays masked, so the order they run in cannot matter:
231/// JSON-object mode's character-class filter
232/// (`ferrox_server::json_mode`), and grammar-constrained decoding
233/// ([`crate::grammar_sampler::GrammarSampler::mask_logits`]).
234///
235/// The signature returns nothing because the callback runs from inside
236/// the sampler, which has no error to return one through. A mask that
237/// CAN fail -- a grammar that dead-ends leaves every logit at `-inf`,
238/// and sampling from that is how an "impossible" request becomes
239/// arbitrary text with a 200 -- records its refusal in the closure's own
240/// captured state, and the decode loop reads it after the sample and
241/// throws the token away. `ferrox_server::sample_step::sample_next` is
242/// the one place that pairing lives.
243pub type LogitMask<'a> = &'a mut dyn FnMut(&mut [f32]);
244
245/// A small, seedable xorshift64* generator. Not cryptographically
246/// secure -- sampling doesn't need that -- but reproducible given a
247/// seed, which greedy argmax already was for free.
248pub struct Sampler {
249    state: u64,
250}
251
252impl Sampler {
253    pub fn new(seed: u64) -> Self {
254        // xorshift64* requires a nonzero seed.
255        Sampler {
256            state: if seed == 0 { 0x9E3779B97F4A7C15 } else { seed },
257        }
258    }
259
260    fn next_u64(&mut self) -> u64 {
261        self.state ^= self.state << 13;
262        self.state ^= self.state >> 7;
263        self.state ^= self.state << 17;
264        // The `*` in xorshift64*. Without it this is plain xorshift64,
265        // whose state IS its output, and a small seed's first output is
266        // therefore still small: for every seed below ~4000 the first
267        // draw landed in the bottom eighth of [0, 1), so a request that
268        // asked for `seed: 42` always got its first token from the
269        // bottom of the CDF. The multiply is what decorrelates the
270        // output from a low-entropy state; see
271        // `low_seeds_do_not_bias_the_first_draw`.
272        self.state.wrapping_mul(0x2545F491_4F6CDD1D)
273    }
274
275    /// Uniform float in [0.0, 1.0).
276    fn next_f32(&mut self) -> f32 {
277        (self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
278    }
279
280    /// Samples one token id from `logits`, given `params` and the
281    /// already-generated `history` (for repetition penalty). Falls back
282    /// to plain greedy argmax when `params.temperature <= 0.0`.
283    ///
284    /// A length-1 `logits` vector is treated as a precomputed greedy token
285    /// id (`logits[0] as usize`) — used by the Metal dense-stack path that
286    /// returns GPU argmax instead of downloading the full vocab.
287    pub fn sample(&mut self, logits: &[f32], params: &SamplingParams, history: &[usize]) -> usize {
288        self.sample_with_mask(logits, params, history, None)
289    }
290
291    /// Like [`Self::sample`], but optionally zeroes disallowed logits via
292    /// `mask` before argmax / nucleus sampling (used for JSON-object mode).
293    pub fn sample_with_mask(
294        &mut self,
295        logits: &[f32],
296        params: &SamplingParams,
297        history: &[usize],
298        mut mask: Option<LogitMask<'_>>,
299    ) -> usize {
300        if params.temperature <= 0.0 && mask.is_none() {
301            if logits.len() == 1 {
302                return logits[0] as usize;
303            }
304            let mut scores = logits.to_vec();
305            apply_history_penalties(&mut scores, params, history);
306            return argmax(&scores);
307        }
308
309        let mut scores: Vec<f32> = logits.to_vec();
310        apply_history_penalties(&mut scores, params, history);
311
312        if let Some(m) = mask.as_mut() {
313            m(&mut scores);
314        }
315
316        if params.temperature <= 0.0 {
317            if scores.len() == 1 {
318                return scores[0] as usize;
319            }
320            return argmax(&scores);
321        }
322
323        let probs = filtered_distribution(scores, params);
324        self.sample_from(&probs)
325    }
326
327    /// A uniform draw in `[0.0, 1.0)`.
328    ///
329    /// Exposed because speculative decoding's accept test is a coin
330    /// flip against `p_target(x) / p_draft(x)` rather than a draw from
331    /// a distribution, and it must come off the same seeded stream as
332    /// every other draw in the run or a "reproducible given a seed"
333    /// generation stops being reproducible.
334    pub fn uniform(&mut self) -> f32 {
335        self.next_f32()
336    }
337
338    /// Draws one index from an already-normalised distribution.
339    ///
340    /// Split out of [`Self::sample_with_mask`] so speculative decoding
341    /// can sample from a distribution it had to compute anyway (the
342    /// rejection rule needs `p_target` itself, not just a draw from it)
343    /// and still go through *exactly* the same draw as ordinary
344    /// sampling. Two separate copies of this loop would be two chances
345    /// to be subtly non-lossless.
346    pub fn sample_from(&mut self, probs: &[f32]) -> usize {
347        let draw = self.next_f32();
348        let mut cumulative = 0.0f32;
349        for (i, &p) in probs.iter().enumerate() {
350            cumulative += p;
351            if draw < cumulative {
352                return i;
353            }
354        }
355        // Floating-point rounding may leave `draw` fractionally above
356        // the final cumulative sum; the last nonzero-probability token
357        // is the correct fallback, not index 0.
358        probs
359            .iter()
360            .enumerate()
361            .rev()
362            .find(|&(_, &p)| p > 0.0)
363            .map(|(i, _)| i)
364            .unwrap_or(0)
365    }
366}
367
368/// The **exact** distribution [`Sampler::sample`] draws from for these
369/// logits, params and history: penalties applied over the
370/// `penalty_last_n` window, then top-k, top-p and min-p, then
371/// temperature, renormalised to sum to 1.
372///
373/// That is llama.cpp's chain order, and it is the order
374/// `filtered_distribution` runs -- **temperature last**, not first.
375/// This comment used to say "temperature divided in, top-k and top-p
376/// filtered", which described the pre-2026-09-01 pipeline and omitted
377/// min-p entirely.
378///
379/// This is what makes lossless speculative verification possible. The
380/// speculative-sampling rejection rule compares `p_target(x)` against
381/// the draft's `q(x)`, and "the target's probability" is meaningless
382/// unless it is the probability the *configured sampler* would actually
383/// have used -- a rule that compared against the raw softmax while the
384/// server sampled with `top_p = 0.9` would be lossless with respect to
385/// a model nobody is running.
386///
387/// Greedy (`temperature <= 0.0`) is a distribution too: the point mass
388/// on the argmax. Returning it as one rather than as a special case is
389/// why the same verification code is correct at every temperature.
390pub fn sampling_distribution(
391    logits: &[f32],
392    params: &SamplingParams,
393    history: &[usize],
394) -> Vec<f32> {
395    let mut scores = logits.to_vec();
396    apply_history_penalties(&mut scores, params, history);
397    if params.temperature <= 0.0 {
398        let mut probs = vec![0.0f32; scores.len()];
399        if let Some(p) = probs.get_mut(argmax(&scores)) {
400            *p = 1.0;
401        }
402        return probs;
403    }
404    filtered_distribution(scores, params)
405}
406
407/// Shared tail of [`Sampler::sample_with_mask`] and
408/// [`sampling_distribution`]: run the already-penalised `scores` through
409/// llama.cpp's sampler chain and return the resulting full-vocabulary
410/// distribution.
411///
412/// # Order, and why it is a specification
413///
414/// llama.cpp's default chain is `penalties, dry, top_n_sigma, top_k,
415/// typical_p, top_p, min_p, xtc, temperature` (`common/common.h:259-269`,
416/// consumed by `common/sampling.cpp:349-397`). The penalties already ran
417/// in [`apply_history_penalties`]; this function is the rest of it, in
418/// that order, and **temperature is last**.
419///
420/// ferrox used to divide by the temperature FIRST and filter afterwards.
421/// That is not a reordering of independent steps. Top-p selects the
422/// smallest set of candidates whose probabilities sum to `p`, and
423/// temperature changes those probabilities: a high temperature flattens
424/// the distribution so the nucleus grows, a low one sharpens it so the
425/// nucleus shrinks. Min-p compares each candidate's logit against
426/// `max + ln(p)`, and temperature scales exactly the gap being compared.
427/// Filtering before scaling and filtering after scaling therefore keep
428/// DIFFERENT candidate sets for the same flags.
429///
430/// Both callers go through here rather than each running their own
431/// chain, because a difference between the two is exactly the kind of
432/// silent non-losslessness speculative verification is supposed to rule
433/// out.
434///
435/// The filters themselves live in [`crate::sampler_chain`], which models
436/// the shrinking candidate list llama.cpp passes down the chain --
437/// including the renormalisation between steps that a keep-mask cannot
438/// express. See that module's header.
439fn filtered_distribution(scores: Vec<f32>, params: &SamplingParams) -> Vec<f32> {
440    let vocab = scores.len();
441    let mut candidates = Candidates::new(&scores);
442    candidates.top_k(params.top_k);
443    candidates.top_p(params.top_p);
444    candidates.min_p(params.min_p);
445    candidates.temperature(params.temperature);
446    candidates.into_distribution(vocab)
447}
448
449/// Penalise tokens that already appear in `history`, once each.
450///
451/// ONCE EACH is the whole subtlety, and ferrox used to get it wrong.
452/// llama.cpp walks the CANDIDATE list and looks each candidate up in a
453/// count map (`llama-sampler.cpp:2735-2756`), so a token repeated `n`
454/// times is divided by `penalty_repeat` exactly once. ferrox walked the
455/// HISTORY, so the same token was divided `n` times and the effective
456/// penalty was `penalty^n`.
457///
458/// That was live on every `ferrox run`: `--repeat-penalty` defaults to
459/// 1.1, so a token seen five times was penalised 1.61x rather than
460/// 1.1x, and the divergence grew with the length of the output.
461///
462/// The sign convention is llama.cpp's and its comment explains it:
463/// dividing alone would make tokens with NEGATIVE logits more likely,
464/// so negatives are multiplied instead.
465fn apply_history_penalties(scores: &mut [f32], params: &SamplingParams, history: &[usize]) {
466    if params.repetition_penalty == 1.0
467        && params.presence_penalty == 0.0
468        && params.frequency_penalty == 0.0
469    {
470        return;
471    }
472    // Only the last `penalty_last_n`, as llama.cpp's ring buffer does.
473    if params.penalty_last_n == 0 {
474        return;
475    }
476    let window = history.len().saturating_sub(params.penalty_last_n);
477    let mut counts = std::collections::HashMap::<usize, usize>::new();
478    for &tok in &history[window..] {
479        *counts.entry(tok).or_insert(0) += 1;
480    }
481    for (tok, count) in counts {
482        let Some(s) = scores.get_mut(tok) else {
483            continue;
484        };
485        if params.repetition_penalty != 1.0 {
486            *s = if *s > 0.0 {
487                *s / params.repetition_penalty
488            } else {
489                *s * params.repetition_penalty
490            };
491        }
492        *s -= params.frequency_penalty * count as f32;
493        *s -= params.presence_penalty;
494    }
495}
496
497fn argmax(logits: &[f32]) -> usize {
498    logits
499        .iter()
500        .enumerate()
501        .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
502        .map(|(i, _)| i)
503        .unwrap_or(0)
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    /// The repetition penalty is applied ONCE per token, however many
511    /// times that token appears in the history.
512    ///
513    /// ferrox walked the history and divided once per OCCURRENCE, so the
514    /// effective penalty was `penalty^n`. llama.cpp walks the candidates
515    /// and looks each up in a count map, so it is `penalty` flat
516    /// (`llama-sampler.cpp:2735-2756`).
517    ///
518    /// Live on every `ferrox run`: `--repeat-penalty` defaults to 1.1,
519    /// so a token seen five times was penalised 1.61x, and the
520    /// divergence grew with the length of the output. Twenty-four
521    /// sampling tests passed with the bug in place, which is why this
522    /// one exists.
523    #[test]
524    fn the_repetition_penalty_does_not_compound_with_repeats() {
525        let params = SamplingParams {
526            temperature: 1.0,
527            top_p: 1.0,
528            top_k: 0,
529            repetition_penalty: 2.0,
530            ..SamplingParams::default()
531        };
532        let logits = vec![4.0f32, 1.0, 1.0];
533
534        // Token 0 appears five times. Penalised once, its score is 2.0;
535        // compounded it would be 4 / 2^5 = 0.125.
536        let mut scores = logits.clone();
537        apply_history_penalties(&mut scores, &params, &[0, 0, 0, 0, 0]);
538        assert!(
539            (scores[0] - 2.0).abs() < 1e-6,
540            "expected one division (2.0), got {} -- {} would be 2^5",
541            scores[0],
542            4.0f32 / 32.0
543        );
544
545        // And once really is once: one occurrence and five occurrences
546        // must land on the same score, or the count still leaks in.
547        let mut once = logits.clone();
548        apply_history_penalties(&mut once, &params, &[0]);
549        assert_eq!(once[0].to_bits(), scores[0].to_bits());
550
551        // A NEGATIVE logit is multiplied rather than divided, or the
552        // penalty would make it more likely -- llama.cpp's own comment.
553        let mut negative = vec![-4.0f32];
554        apply_history_penalties(&mut negative, &params, &[0, 0, 0]);
555        assert!((negative[0] + 8.0).abs() < 1e-6, "got {}", negative[0]);
556    }
557
558    /// The penalties look at the last `penalty_last_n` tokens, not the
559    /// whole history.
560    ///
561    /// llama.cpp keeps a ring buffer of `penalty_last_n` (default 64,
562    /// `common/common.h:238`); ferrox scanned everything generated so
563    /// far. On a long generation that is a steadily growing set of
564    /// penalised tokens against llama.cpp's fixed 64 -- the divergence
565    /// grows with output length, which is when a repetition penalty
566    /// matters most.
567    #[test]
568    fn the_penalties_only_see_the_last_n_tokens() {
569        let params = SamplingParams {
570            repetition_penalty: 2.0,
571            penalty_last_n: 2,
572            ..SamplingParams::default()
573        };
574        let mut scores = vec![8.0f32, 8.0, 8.0];
575        // Token 0 fell out of the window; tokens 1 and 2 are in it.
576        apply_history_penalties(&mut scores, &params, &[0, 1, 2]);
577        assert_eq!(
578            scores[0].to_bits(),
579            8.0f32.to_bits(),
580            "token 0 is outside the window"
581        );
582        assert!((scores[1] - 4.0).abs() < 1e-6, "got {}", scores[1]);
583        assert!((scores[2] - 4.0).abs() < 1e-6, "got {}", scores[2]);
584
585        // `0` disables the penalties outright, as llama.cpp documents.
586        let off = SamplingParams {
587            penalty_last_n: 0,
588            ..params
589        };
590        let mut untouched = vec![8.0f32; 3];
591        apply_history_penalties(&mut untouched, &off, &[0, 1, 2]);
592        assert_eq!(untouched, vec![8.0f32; 3]);
593
594        // A window longer than the history is not an overflow.
595        let wide = SamplingParams {
596            penalty_last_n: 1000,
597            ..params
598        };
599        let mut short = vec![8.0f32];
600        apply_history_penalties(&mut short, &wide, &[0]);
601        assert!((short[0] - 4.0).abs() < 1e-6);
602    }
603
604    /// Frequency penalty still scales with the count, while the
605    /// repetition penalty does not.
606    ///
607    /// Both live in the same loop, so a fix that made the repetition
608    /// penalty flat by dropping the counts would break this one.
609    #[test]
610    fn the_frequency_penalty_still_counts_repeats() {
611        let params = SamplingParams {
612            frequency_penalty: 0.5,
613            presence_penalty: 0.25,
614            ..SamplingParams::default()
615        };
616        let mut scores = vec![10.0f32];
617        apply_history_penalties(&mut scores, &params, &[0, 0, 0, 0]);
618        // 10 - 0.5*4 - 0.25 = 7.75
619        assert!((scores[0] - 7.75).abs() < 1e-6, "got {}", scores[0]);
620    }
621
622    /// Top-p cuts the UNSCALED distribution; the temperature reshapes
623    /// only the survivors.
624    ///
625    /// llama.cpp's default chain runs temperature LAST
626    /// (`common/common.h:259-269`); ferrox divided first and filtered
627    /// afterwards. Not an innocuous reordering: temperature changes the
628    /// probabilities top-p sums over, so a high temperature flattens the
629    /// distribution and grows the nucleus. The two orders keep different
630    /// candidate sets for identical flags.
631    #[test]
632    fn temperature_does_not_change_which_candidates_top_p_keeps() {
633        let logits = vec![3.0f32, 2.0, 1.0, 0.0];
634        let at = |temperature: f32| -> Vec<bool> {
635            let params = SamplingParams {
636                temperature,
637                top_p: 0.9,
638                top_k: 0,
639                ..SamplingParams::default()
640            };
641            sampling_distribution(&logits, &params, &[])
642                .iter()
643                .map(|&p| p > 0.0)
644                .collect()
645        };
646
647        let cold = at(0.5);
648        let hot = at(4.0);
649        assert_eq!(
650            cold, hot,
651            "the surviving set must not depend on the temperature: \
652             cold={cold:?} hot={hot:?}"
653        );
654        // And the cut must actually bite, or the equality above is
655        // satisfied by keeping everything.
656        assert!(
657            cold.iter().any(|&k| !k),
658            "top_p = 0.9 must drop at least one of these four candidates"
659        );
660    }
661
662    /// min-p truncates, and it truncates on llama.cpp's threshold.
663    ///
664    /// llama.cpp enables min-p **by default** at 0.05
665    /// (`common/common.h:231`), so until this existed ferrox could not
666    /// reproduce llama.cpp's own out-of-the-box output on any prompt --
667    /// a parity gap, not a missing feature.
668    ///
669    /// Logits `[4, 3, 2, 1]` at `min_p = 0.2`: the threshold is
670    /// `4 + ln(0.2) = 2.3905`, so exactly the candidates at 4 and 3
671    /// survive. Arithmetic done by hand from
672    /// `src/llama-sampler.cpp:1556`, not read back off the code.
673    #[test]
674    fn min_p_truncates_at_ln_p_below_the_top_logit() {
675        let logits = vec![4.0f32, 3.0, 2.0, 1.0];
676        let params = SamplingParams {
677            temperature: 1.0,
678            min_p: 0.2,
679            ..SamplingParams::default()
680        };
681        let probs = sampling_distribution(&logits, &params, &[]);
682        assert!(probs[0] > 0.0 && probs[1] > 0.0);
683        assert_eq!(probs[2], 0.0, "2.0 is below 4 + ln(0.2) = 2.3905");
684        assert_eq!(probs[3], 0.0);
685        assert!((probs.iter().sum::<f32>() - 1.0).abs() < 1e-6);
686
687        // The two survivors are renormalised against each other:
688        // e^4 / (e^4 + e^3) = 0.7311.
689        assert!((probs[0] - 0.731_059).abs() < 1e-5, "got {}", probs[0]);
690
691        // 0.0 disables it, which is ferrox's struct default -- adding
692        // min-p must not change any existing caller's distribution.
693        let off = SamplingParams {
694            min_p: 0.0,
695            ..params.clone()
696        };
697        let unfiltered = sampling_distribution(&logits, &off, &[]);
698        assert!(unfiltered.iter().all(|&p| p > 0.0));
699    }
700
701    /// min-p runs BEFORE the temperature, so the set it keeps does not
702    /// depend on `--temp`.
703    ///
704    /// This is the same trap as E4 and it bites harder here. min-p's
705    /// test is `logit_i >= logit_max + ln(p)`, and temperature divides
706    /// **both** logits, so it scales the very gap being compared against
707    /// a fixed `ln(p)`. On these logits at `min_p = 0.2`, running min-p
708    /// after a temperature of 0.5 would keep one candidate and after 2.0
709    /// would keep all four; llama.cpp keeps two at every temperature
710    /// (`common/common.h:259-269` puts `MIN_P` before `TEMPERATURE`).
711    ///
712    /// Move `candidates.min_p(..)` after `candidates.temperature(..)` in
713    /// `filtered_distribution` and this goes red.
714    #[test]
715    fn temperature_does_not_change_which_candidates_min_p_keeps() {
716        let logits = vec![3.0f32, 2.0, 1.0, 0.0];
717        let survivors = |temperature: f32| -> Vec<bool> {
718            let params = SamplingParams {
719                temperature,
720                min_p: 0.2,
721                ..SamplingParams::default()
722            };
723            sampling_distribution(&logits, &params, &[])
724                .iter()
725                .map(|&p| p > 0.0)
726                .collect()
727        };
728
729        let cold = survivors(0.5);
730        let warm = survivors(1.0);
731        let hot = survivors(2.0);
732        assert_eq!(cold, warm, "cold={cold:?} warm={warm:?}");
733        assert_eq!(warm, hot, "warm={warm:?} hot={hot:?}");
734        // 3 + ln(0.2) = 1.3905, so exactly the 3.0 and 2.0 candidates.
735        assert_eq!(warm, vec![true, true, false, false]);
736    }
737
738    /// min-p sits AFTER top-p in the chain, and both may bite on the
739    /// same call.
740    ///
741    /// `top_p = 0.95` on this distribution keeps three candidates
742    /// (0.6337 + 0.2331 + 0.0857 = 0.9525); min-p at 0.2 then drops the
743    /// third, whose probability is 0.135 of the top one. Getting only
744    /// one of the two filters gives a different answer either way, so
745    /// this fails if either is dropped or if min-p is skipped when top-p
746    /// already truncated.
747    #[test]
748    fn top_p_and_min_p_both_apply() {
749        let logits = vec![3.0f32, 2.0, 1.0, 0.0];
750        let params = SamplingParams {
751            temperature: 1.0,
752            top_p: 0.95,
753            min_p: 0.2,
754            ..SamplingParams::default()
755        };
756        let probs = sampling_distribution(&logits, &params, &[]);
757        assert_eq!(
758            probs.iter().map(|&p| p > 0.0).collect::<Vec<_>>(),
759            vec![true, true, false, false]
760        );
761
762        // top-p alone keeps three; min-p alone also keeps two here, so
763        // pin the top-p-only case to prove the two filters are distinct
764        // and that this test is not satisfied by min-p doing all the
765        // work.
766        let top_p_only = SamplingParams {
767            min_p: 0.0,
768            ..params.clone()
769        };
770        assert_eq!(
771            sampling_distribution(&logits, &top_p_only, &[])
772                .iter()
773                .filter(|&&p| p > 0.0)
774                .count(),
775            3
776        );
777    }
778
779    #[test]
780    fn temperature_zero_accepts_precomputed_argmax_singleton() {
781        let mut sampler = Sampler::new(1);
782        let params = SamplingParams::default();
783        assert_eq!(sampler.sample(&[42.0], &params, &[]), 42);
784        // Non-greedy must not treat a singleton as a token id.
785        let sampled = SamplingParams {
786            temperature: 0.8,
787            ..SamplingParams::default()
788        };
789        // Softmax of a single logit → only token 0 is eligible.
790        assert_eq!(sampler.sample(&[42.0], &sampled, &[]), 0);
791    }
792
793    #[test]
794    fn temperature_zero_is_deterministic_greedy_argmax() {
795        let logits = vec![0.1, 0.9, 0.3, -0.2];
796        let params = SamplingParams::default();
797        let mut sampler = Sampler::new(42);
798        assert_eq!(sampler.sample(&logits, &params, &[]), 1);
799        // Must be deterministic regardless of RNG state advancing.
800        assert_eq!(sampler.sample(&logits, &params, &[]), 1);
801    }
802
803    #[test]
804    fn high_temperature_can_pick_a_non_argmax_token_over_many_draws() {
805        let logits = vec![1.0, 1.0, 1.0, 1.0];
806        let params = SamplingParams {
807            temperature: 1.0,
808            ..SamplingParams::default()
809        };
810        let mut sampler = Sampler::new(7);
811        let mut seen = std::collections::HashSet::new();
812        for _ in 0..200 {
813            seen.insert(sampler.sample(&logits, &params, &[]));
814        }
815        assert!(
816            seen.len() > 1,
817            "uniform logits at temperature=1.0 must produce more than one distinct token across 200 draws"
818        );
819    }
820
821    #[test]
822    fn top_k_one_is_equivalent_to_greedy() {
823        let logits = vec![0.1, 0.9, 0.3, -0.2];
824        let params = SamplingParams {
825            temperature: 1.0,
826            top_k: 1,
827            ..SamplingParams::default()
828        };
829        let mut sampler = Sampler::new(123);
830        for _ in 0..20 {
831            assert_eq!(sampler.sample(&logits, &params, &[]), 1);
832        }
833    }
834
835    #[test]
836    fn top_p_near_zero_is_equivalent_to_greedy() {
837        let logits = vec![0.1, 5.0, 0.3, -0.2];
838        let params = SamplingParams {
839            temperature: 1.0,
840            top_p: 0.001,
841            ..SamplingParams::default()
842        };
843        let mut sampler = Sampler::new(9);
844        for _ in 0..20 {
845            assert_eq!(sampler.sample(&logits, &params, &[]), 1);
846        }
847    }
848
849    #[test]
850    fn presence_and_frequency_penalties_reduce_seen_token_logits() {
851        let logits = vec![0.0, 5.0, 0.0];
852        let params = SamplingParams {
853            temperature: 1.0,
854            presence_penalty: 10.0,
855            frequency_penalty: 0.0,
856            ..SamplingParams::default()
857        };
858        let mut sampler = Sampler::new(1);
859        let mut counts = [0usize; 3];
860        for _ in 0..500 {
861            counts[sampler.sample(&logits, &params, &[1])] += 1;
862        }
863        assert!(
864            counts[1] < 250,
865            "presence_penalty should discourage token 1; counts={counts:?}"
866        );
867
868        let params = SamplingParams {
869            temperature: 1.0,
870            presence_penalty: 0.0,
871            frequency_penalty: 10.0,
872            ..SamplingParams::default()
873        };
874        let mut sampler = Sampler::new(2);
875        counts = [0; 3];
876        for _ in 0..500 {
877            counts[sampler.sample(&logits, &params, &[1, 1, 1])] += 1;
878        }
879        assert!(
880            counts[1] < 250,
881            "frequency_penalty should discourage repeated token 1; counts={counts:?}"
882        );
883    }
884
885    #[test]
886    fn repetition_penalty_reduces_probability_of_recently_seen_token() {
887        let logits = vec![0.0, 5.0, 0.0];
888        let params = SamplingParams {
889            temperature: 1.0,
890            repetition_penalty: 1000.0,
891            ..SamplingParams::default()
892        };
893        let mut sampler = Sampler::new(3);
894        let mut counts = [0usize; 3];
895        for _ in 0..500 {
896            counts[sampler.sample(&logits, &params, &[1])] += 1;
897        }
898        assert!(
899            counts[1] < 250,
900            "heavily penalizing token 1 (already in history) should make it far less likely than its raw logit alone would suggest; got counts={counts:?}"
901        );
902    }
903
904    #[test]
905    fn low_seeds_do_not_bias_the_first_draw() {
906        // Every generation seeds a fresh `Sampler` (the server does it
907        // per request, from the caller's `seed`), so the FIRST draw off
908        // a freshly seeded generator is the one users actually see.
909        // Plain xorshift64 returns its own state, so seeds 1..4000 all
910        // produced a first draw in the bottom eighth of [0, 1) -- the
911        // first sampled token of every seeded request came off the
912        // bottom of the CDF.
913        let vocab = 8;
914        let logits = vec![0.0f32; vocab];
915        let params = SamplingParams {
916            temperature: 1.0,
917            ..SamplingParams::default()
918        };
919        let seeds = 4_000u64;
920        let mut counts = vec![0usize; vocab];
921        for seed in 1..=seeds {
922            counts[Sampler::new(seed).sample(&logits, &params, &[])] += 1;
923        }
924        let expected = seeds as f64 / vocab as f64;
925        for (token, &c) in counts.iter().enumerate() {
926            assert!(
927                (c as f64 - expected).abs() < expected * 0.25,
928                "uniform logits: token {token} came up {c} times across {seeds} seeds, \
929                 expected about {expected:.0} (counts={counts:?})"
930            );
931        }
932    }
933
934    #[test]
935    fn the_published_distribution_is_the_one_sample_actually_draws_from() {
936        // `sampling_distribution` is load-bearing for lossless
937        // speculative verification: if it disagreed with what `sample`
938        // draws from, every accept/reject decision would be measured
939        // against the wrong target. Check them against each other
940        // empirically, with filters on so the two code paths have
941        // something to disagree about.
942        let logits = vec![0.4, 2.0, -1.0, 1.2, 0.9, -0.3];
943        let params = SamplingParams {
944            temperature: 0.8,
945            top_p: 0.9,
946            top_k: 4,
947            repetition_penalty: 1.3,
948            ..SamplingParams::default()
949        };
950        let history = [1usize, 4];
951        let claimed = sampling_distribution(&logits, &params, &history);
952        assert!((claimed.iter().sum::<f32>() - 1.0).abs() < 1e-5);
953
954        let draws = 100_000;
955        let mut counts = vec![0usize; logits.len()];
956        let mut sampler = Sampler::new(0xC0FFEE);
957        for _ in 0..draws {
958            counts[sampler.sample(&logits, &params, &history)] += 1;
959        }
960        for (i, &c) in counts.iter().enumerate() {
961            let empirical = c as f64 / draws as f64;
962            assert!(
963                (empirical - claimed[i] as f64).abs() < 0.01,
964                "token {i}: sample() draws it {empirical:.4} of the time but \
965                 sampling_distribution claims {:.4}",
966                claimed[i]
967            );
968        }
969    }
970
971    #[test]
972    fn greedy_is_published_as_a_point_mass_not_a_special_case() {
973        let logits = vec![0.1, 0.9, 0.3, -0.2];
974        let probs = sampling_distribution(&logits, &SamplingParams::default(), &[]);
975        assert_eq!(probs, vec![0.0, 1.0, 0.0, 0.0]);
976        // Penalties still apply at temperature 0, so the point mass
977        // moves with them.
978        let penalized = sampling_distribution(
979            &logits,
980            &SamplingParams {
981                repetition_penalty: 100.0,
982                ..SamplingParams::default()
983            },
984            &[1],
985        );
986        assert_eq!(penalized[1], 0.0);
987        assert_eq!(penalized.iter().sum::<f32>(), 1.0);
988    }
989
990    #[test]
991    fn degenerate_all_zero_probability_falls_back_to_greedy() {
992        // top_k=1 combined with a top_p that would exclude even that
993        // one surviving token is a contradictory/degenerate
994        // configuration; must not panic or sample index 0 blindly.
995        let logits = vec![0.1, 0.9, 0.3, -0.2];
996        let params = SamplingParams {
997            temperature: 1.0,
998            top_k: 1,
999            top_p: 1.0,
1000            ..SamplingParams::default()
1001        };
1002        let mut sampler = Sampler::new(1);
1003        assert_eq!(sampler.sample(&logits, &params, &[]), 1);
1004    }
1005
1006    /// Only the keys the file actually carries become a recommendation.
1007    ///
1008    /// **This test fails if an absent key is filled with a house
1009    /// default** (the naive reading, and what HF's own
1010    /// `GenerationConfig` object does): `top_p` and `top_k` would come
1011    /// back as `Some(1.0)` / `Some(0)` and would then override whatever
1012    /// the server itself defaults to, for values this checkpoint never
1013    /// expressed.
1014    #[test]
1015    fn an_absent_generation_config_key_stays_absent_rather_than_taking_a_default() {
1016        let recommended = RecommendedSampling::from_generation_config(r#"{"temperature": 0.6}"#);
1017        assert_eq!(recommended.temperature, Some(0.6));
1018        assert_eq!(recommended.top_p, None, "top_p was not in the file");
1019        assert_eq!(recommended.top_k, None, "top_k was not in the file");
1020        // An explicit JSON null is silence too (the reference's
1021        // `if val is not None`).
1022        let nulled = RecommendedSampling::from_generation_config(r#"{"top_p": null}"#);
1023        assert_eq!(nulled, RecommendedSampling::default());
1024    }
1025
1026    /// A reasoning checkpoint's full recommendation survives intact --
1027    /// the case the whole path exists for (Qwen3.5: temp 1.0, top_k 20,
1028    /// top_p 0.95).
1029    #[test]
1030    fn every_generation_config_key_present_is_recommended() {
1031        let recommended = RecommendedSampling::from_generation_config(
1032            r#"{"do_sample": true, "temperature": 1.0, "top_k": 20, "top_p": 0.95}"#,
1033        );
1034        assert_eq!(
1035            recommended,
1036            RecommendedSampling {
1037                temperature: Some(1.0),
1038                top_p: Some(0.95),
1039                top_k: Some(20),
1040            }
1041        );
1042    }
1043
1044    /// `do_sample: false` recommends greedy, expressed as temperature 0
1045    /// and *nothing else*: the top_k/top_p such a file also carries
1046    /// describe a sampler it is asking not to be used, so returning them
1047    /// would filter a distribution the model wants collapsed to its
1048    /// argmax.
1049    #[test]
1050    fn do_sample_false_recommends_greedy_and_no_other_field() {
1051        let recommended = RecommendedSampling::from_generation_config(
1052            r#"{"do_sample": false, "temperature": 0.7, "top_k": 50, "top_p": 0.9}"#,
1053        );
1054        assert_eq!(recommended.temperature, Some(0.0));
1055        assert_eq!(recommended.top_p, None);
1056        assert_eq!(recommended.top_k, None);
1057    }
1058
1059    /// A sidecar that does not parse must not be able to change how the
1060    /// model is sampled.
1061    #[test]
1062    fn a_malformed_generation_config_recommends_nothing() {
1063        for text in ["", "not json", "[1, 2, 3]", "null"] {
1064            assert!(
1065                RecommendedSampling::from_generation_config(text).is_empty(),
1066                "{text:?} must recommend nothing"
1067            );
1068        }
1069    }
1070
1071    /// Precedence: the request wins over the checkpoint, and the
1072    /// checkpoint only fills what the request left unset. An explicit
1073    /// `temperature: 0` from a client must stay reachable on a model
1074    /// that recommends 1.0.
1075    #[test]
1076    fn a_request_outranks_the_recommendation_which_outranks_the_framework_default() {
1077        let recommended = RecommendedSampling {
1078            temperature: Some(1.0),
1079            top_p: Some(0.95),
1080            top_k: Some(20),
1081        };
1082        let resolved = recommended.resolve(
1083            RequestedSampling {
1084                temperature: Some(0.0),
1085                ..RequestedSampling::default()
1086            },
1087            SamplingParams::default(),
1088        );
1089        assert_eq!(resolved.temperature, 0.0, "the request asked for greedy");
1090        assert_eq!(resolved.top_p, 0.95, "the request said nothing about top_p");
1091        assert_eq!(resolved.top_k, 20, "the request said nothing about top_k");
1092        // Penalties are never recommended, only carried through.
1093        assert_eq!(resolved.repetition_penalty, 1.0);
1094    }
1095
1096    /// A checkpoint that recommends nothing must leave ferrox's existing
1097    /// behaviour bit-identical: greedy, unfiltered, exactly
1098    /// `SamplingParams::default()`.
1099    #[test]
1100    fn a_checkpoint_that_recommends_nothing_leaves_the_framework_defaults_alone() {
1101        let resolved = RecommendedSampling::default()
1102            .resolve(RequestedSampling::default(), SamplingParams::default());
1103        let default = SamplingParams::default();
1104        assert_eq!(resolved.temperature, default.temperature);
1105        assert_eq!(resolved.top_p, default.top_p);
1106        assert_eq!(resolved.top_k, default.top_k);
1107    }
1108
1109    /// A model directory with no `generation_config.json` recommends
1110    /// nothing rather than failing: the absence of a recommendation is
1111    /// the normal case for most checkpoints.
1112    #[test]
1113    fn a_model_directory_without_a_generation_config_recommends_nothing() {
1114        let dir = std::env::temp_dir().join(format!(
1115            "ferrox_test_no_generation_config_{}",
1116            std::process::id()
1117        ));
1118        std::fs::create_dir_all(&dir).unwrap();
1119        assert!(RecommendedSampling::from_model_dir(&dir).is_empty());
1120        std::fs::remove_dir_all(&dir).ok();
1121    }
1122
1123    /// The sidecar is read from the directory beside the weights, the
1124    /// same place HF's `GenerationConfig.from_pretrained` looks.
1125    #[test]
1126    fn a_model_directory_generation_config_is_read_from_beside_the_weights() {
1127        let dir = std::env::temp_dir().join(format!(
1128            "ferrox_test_generation_config_dir_{}",
1129            std::process::id()
1130        ));
1131        std::fs::create_dir_all(&dir).unwrap();
1132        std::fs::write(
1133            dir.join("generation_config.json"),
1134            r#"{"temperature": 0.6, "top_p": 0.95}"#,
1135        )
1136        .unwrap();
1137        let recommended = RecommendedSampling::from_model_dir(&dir);
1138        std::fs::remove_dir_all(&dir).ok();
1139        assert_eq!(recommended.temperature, Some(0.6));
1140        assert_eq!(recommended.top_p, Some(0.95));
1141        assert_eq!(recommended.top_k, None);
1142    }
1143}