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::penalty_window::PenaltyWindow;
18use crate::sampler_chain::Candidates;
19use crate::sampler_order::{ChainStep, SamplerOrder};
20
21/// Sampling parameters for one generation request. `temperature <= 0.0`
22/// means "sample nothing, take the greedy argmax" -- the same
23/// deterministic behavior ferrox always had before this module existed.
24#[derive(Debug, Clone)]
25pub struct SamplingParams {
26    pub temperature: f32,
27    /// Nucleus sampling threshold in (0.0, 1.0]. 1.0 disables top-p
28    /// filtering (every token with nonzero probability is eligible).
29    pub top_p: f32,
30    /// Keep only candidates at least `min_p` times as likely as the most
31    /// likely one. `0.0` disables it; llama.cpp's `--min-p`, whose
32    /// default is **0.05** (`common/common.h:231`) rather than off.
33    ///
34    /// That default is why this is a parity item and not a feature:
35    /// llama.cpp truncates with min-p on every run nobody configured,
36    /// so without it ferrox could not reproduce llama.cpp's *own*
37    /// out-of-the-box output for any prompt.
38    ///
39    /// The struct default here stays `0.0` (disabled) for the same
40    /// reason `temperature` defaults to greedy: `SamplingParams::default`
41    /// is ferrox's "do nothing the caller did not ask for" baseline, and
42    /// llama.cpp's CLI numbers live on the CLI flags.
43    pub min_p: f32,
44    /// Keep only the `top_k` highest-probability tokens before
45    /// sampling. 0 disables top-k filtering.
46    pub top_k: usize,
47    /// > 1.0 discourages repeating a token already in the
48    /// > [`PenaltyWindow`] -- prompt included; 1.0
49    /// > disables repetition penalty. Uses the standard convention
50    /// > (divide positive logits, multiply negative ones) so the penalty
51    /// > always pushes toward *less* likely, regardless of logit sign.
52    pub repetition_penalty: f32,
53    /// How many of the most recent tokens the penalties look at, as
54    /// llama.cpp's `penalty_last_n` (`common/common.h:238`, default 64).
55    ///
56    /// `0` disables the penalties entirely. ferrox had no window at all
57    /// and scanned the WHOLE history, so on a long generation it
58    /// penalised a steadily growing set of tokens where llama.cpp
59    /// penalises the last 64 -- the divergence grew with output length,
60    /// which is exactly when a repetition penalty matters most.
61    pub penalty_last_n: usize,
62    /// OpenAI-style presence penalty: subtract from logits of tokens
63    /// that already appeared in the [`PenaltyWindow`] (once per
64    /// distinct token).
65    pub presence_penalty: f32,
66    /// OpenAI-style frequency penalty: subtract `frequency_penalty *
67    /// count` from logits for each token id seen in the
68    /// [`PenaltyWindow`].
69    pub frequency_penalty: f32,
70    /// The ORDER the chain above runs in, llama.cpp's `--samplers`.
71    ///
72    /// Not a cosmetic setting. Each filter renormalises over the
73    /// survivors of the last one, so moving a step changes which
74    /// candidates the next step can see -- ferrox has already shipped
75    /// that bug once, with temperature running first.
76    ///
77    /// The default is ferrox's existing chain
78    /// (`penalties;top_k;top_p;min_p;temperature`), so a caller that
79    /// never touches this field samples exactly what it always did. See
80    /// [`crate::sampler_order`].
81    pub sampler_order: SamplerOrder,
82}
83
84impl Default for SamplingParams {
85    /// Greedy decoding: identical behavior to ferrox's original
86    /// argmax-only generation loop.
87    fn default() -> Self {
88        SamplingParams {
89            temperature: 0.0,
90            top_p: 1.0,
91            min_p: 0.0,
92            top_k: 0,
93            repetition_penalty: 1.0,
94            penalty_last_n: 64,
95            presence_penalty: 0.0,
96            frequency_penalty: 0.0,
97            sampler_order: SamplerOrder::default(),
98        }
99    }
100}
101
102/// The sampling a **checkpoint recommends for itself**, one `Option`
103/// per field so that "this model says nothing about top_p" stays
104/// distinguishable from "this model recommends top_p = 1.0". This is
105/// sglang's `sampling_defaults='model'`, ported from FreeToken
106/// `python/freetoken/utils/hf.py:92 load_generation_sampling`.
107///
108/// Every field is `None` for a checkpoint that recommends nothing,
109/// which is the overwhelming majority, and
110/// [`RecommendedSampling::resolve`] then reproduces ferrox's existing
111/// defaults exactly -- a recommendation may only fill a gap the request
112/// left, never override it.
113///
114/// Why this exists at all: reasoning checkpoints are tuned for a
115/// specific sampler (Qwen3.5 ships temperature 1.0, top_k 20, top_p
116/// 0.95) and ship those numbers with the weights. Served under a
117/// generic greedy-or-0.8 default they fall into repetition loops --
118/// fluent output that never terminates -- which reads as a broken model
119/// rather than as a serving default nobody read off the file.
120#[derive(Debug, Clone, Copy, Default, PartialEq)]
121pub struct RecommendedSampling {
122    pub temperature: Option<f32>,
123    pub top_p: Option<f32>,
124    pub top_k: Option<usize>,
125}
126
127/// The sampling fields **one request** actually specified. `None` means
128/// the request said nothing about that field, so the checkpoint's
129/// recommendation (and then the framework default) may speak for it.
130///
131/// Collapsing this to a plain [`SamplingParams`] at the wire boundary
132/// -- `temperature: req.temperature.unwrap_or(0.0)` -- is what destroys
133/// the distinction: a request that omitted `temperature` becomes
134/// indistinguishable from one that explicitly asked for greedy, and no
135/// recommendation can ever apply.
136#[derive(Debug, Clone, Copy, Default, PartialEq)]
137pub struct RequestedSampling {
138    pub temperature: Option<f32>,
139    pub top_p: Option<f32>,
140    pub top_k: Option<usize>,
141}
142
143impl RecommendedSampling {
144    /// True when the checkpoint recommended nothing at all, i.e.
145    /// [`Self::resolve`] is guaranteed to return the framework defaults
146    /// for any request. Useful for telling an operator whether
147    /// "model defaults" had anything to act on.
148    pub fn is_empty(&self) -> bool {
149        *self == RecommendedSampling::default()
150    }
151
152    /// Precedence, exactly as FreeToken's `resolve_sampling.pick`
153    /// (`python/freetoken/server/generation.py:170`) applies it: the
154    /// **request's** own value, else the **checkpoint's**
155    /// recommendation, else the **framework** default carried by
156    /// `framework` (ferrox's `SamplingParams::default()` unless a caller
157    /// has its own).
158    ///
159    /// The penalty fields are taken from `framework` untouched: nothing
160    /// in the reference reads a recommended penalty, and inventing one
161    /// here would be this function changing generation on its own.
162    ///
163    /// Getting the order wrong in either direction is a silent
164    /// behaviour change: recommendation-over-request makes a client's
165    /// explicit `temperature: 0` unreachable on a model that recommends
166    /// 1.0, and framework-over-recommendation is the greedy repetition
167    /// loop this whole path exists to avoid.
168    pub fn resolve(
169        &self,
170        requested: RequestedSampling,
171        framework: SamplingParams,
172    ) -> SamplingParams {
173        SamplingParams {
174            temperature: requested
175                .temperature
176                .or(self.temperature)
177                .unwrap_or(framework.temperature),
178            top_p: requested.top_p.or(self.top_p).unwrap_or(framework.top_p),
179            top_k: requested.top_k.or(self.top_k).unwrap_or(framework.top_k),
180            ..framework
181        }
182    }
183
184    /// The recommendation in a HuggingFace-style `generation_config.json`
185    /// body.
186    ///
187    /// Two rules, both from the reference
188    /// (`hf.py:92 load_generation_sampling`):
189    ///
190    /// * `do_sample: false` means the checkpoint recommends **greedy**,
191    ///   which is returned as `temperature = 0` and *nothing else* --
192    ///   the top_k/top_p in such a file describe a sampler the model
193    ///   asks not to be used.
194    /// * otherwise only the keys **actually present** are returned. An
195    ///   absent key stays `None`; filling it with a house default (the
196    ///   naive reading, and what HF's own `GenerationConfig` object does
197    ///   for you) would turn silence into a recommendation and let a
198    ///   file that says only `temperature: 0.6` also pin top_p to 1.0,
199    ///   overriding the server's own default for a value the checkpoint
200    ///   never expressed.
201    ///
202    /// A file that does not parse, or is not a JSON object, recommends
203    /// nothing -- a malformed sidecar must not be able to change how a
204    /// model is sampled.
205    pub fn from_generation_config(json: &str) -> Self {
206        let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(json)
207        else {
208            return RecommendedSampling::default();
209        };
210        if map.get("do_sample").and_then(|v| v.as_bool()) == Some(false) {
211            return RecommendedSampling {
212                temperature: Some(0.0),
213                ..RecommendedSampling::default()
214            };
215        }
216        RecommendedSampling {
217            temperature: map
218                .get("temperature")
219                .and_then(|v| v.as_f64())
220                .map(|v| v as f32),
221            top_p: map.get("top_p").and_then(|v| v.as_f64()).map(|v| v as f32),
222            top_k: map
223                .get("top_k")
224                .and_then(|v| v.as_u64())
225                .map(|v| v as usize),
226        }
227    }
228
229    /// [`Self::from_generation_config`] for the `generation_config.json`
230    /// beside a checkpoint's weights (an HF-format model directory).
231    ///
232    /// A directory with no such file recommends nothing, exactly like a
233    /// GGUF with no `general.sampling.*` keys: the absence of a
234    /// recommendation is the normal case and must never be an error that
235    /// stops a model from loading.
236    pub fn from_model_dir(dir: &std::path::Path) -> Self {
237        match std::fs::read_to_string(dir.join("generation_config.json")) {
238            Ok(text) => Self::from_generation_config(&text),
239            Err(_) => RecommendedSampling::default(),
240        }
241    }
242}
243
244/// Sets logits a caller wants to forbid to `-inf`, in place, before the
245/// sampler looks at them.
246///
247/// Two callers today, and they COMPOSE rather than exclude each other --
248/// a masked logit stays masked, so the order they run in cannot matter:
249/// JSON-object mode's character-class filter
250/// (`ferrox_server::json_mode`), and grammar-constrained decoding
251/// ([`crate::grammar_sampler::GrammarSampler::mask_logits`]).
252///
253/// The signature returns nothing because the callback runs from inside
254/// the sampler, which has no error to return one through. A mask that
255/// CAN fail -- a grammar that dead-ends leaves every logit at `-inf`,
256/// and sampling from that is how an "impossible" request becomes
257/// arbitrary text with a 200 -- records its refusal in the closure's own
258/// captured state, and the decode loop reads it after the sample and
259/// throws the token away. `ferrox_server::sample_step::sample_next` is
260/// the one place that pairing lives.
261pub type LogitMask<'a> = &'a mut dyn FnMut(&mut [f32]);
262
263/// A small, seedable xorshift64* generator. Not cryptographically
264/// secure -- sampling doesn't need that -- but reproducible given a
265/// seed, which greedy argmax already was for free.
266pub struct Sampler {
267    state: u64,
268}
269
270impl Sampler {
271    pub fn new(seed: u64) -> Self {
272        // xorshift64* requires a nonzero seed.
273        Sampler {
274            state: if seed == 0 { 0x9E3779B97F4A7C15 } else { seed },
275        }
276    }
277
278    fn next_u64(&mut self) -> u64 {
279        self.state ^= self.state << 13;
280        self.state ^= self.state >> 7;
281        self.state ^= self.state << 17;
282        // The `*` in xorshift64*. Without it this is plain xorshift64,
283        // whose state IS its output, and a small seed's first output is
284        // therefore still small: for every seed below ~4000 the first
285        // draw landed in the bottom eighth of [0, 1), so a request that
286        // asked for `seed: 42` always got its first token from the
287        // bottom of the CDF. The multiply is what decorrelates the
288        // output from a low-entropy state; see
289        // `low_seeds_do_not_bias_the_first_draw`.
290        self.state.wrapping_mul(0x2545F491_4F6CDD1D)
291    }
292
293    /// Uniform float in [0.0, 1.0).
294    fn next_f32(&mut self) -> f32 {
295        (self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
296    }
297
298    /// Samples one token id from `logits`, given `params` and the
299    /// [`PenaltyWindow`] the penalties look back over. Falls back to
300    /// plain greedy argmax when `params.temperature <= 0.0`.
301    ///
302    /// `history` is a window and not a slice on purpose: it carries the
303    /// PROMPT as well as the generated tokens, which is what llama.cpp
304    /// penalises over. See [`crate::penalty_window`].
305    ///
306    /// A length-1 `logits` vector is treated as a precomputed greedy token
307    /// id (`logits[0] as usize`) — used by the Metal dense-stack path that
308    /// returns GPU argmax instead of downloading the full vocab.
309    pub fn sample(
310        &mut self,
311        logits: &[f32],
312        params: &SamplingParams,
313        history: PenaltyWindow<'_>,
314    ) -> usize {
315        self.sample_with_mask(logits, params, history, None)
316    }
317
318    /// Like [`Self::sample`], but optionally zeroes disallowed logits via
319    /// `mask` before argmax / nucleus sampling (used for JSON-object mode).
320    pub fn sample_with_mask(
321        &mut self,
322        logits: &[f32],
323        params: &SamplingParams,
324        history: PenaltyWindow<'_>,
325        mut mask: Option<LogitMask<'_>>,
326    ) -> usize {
327        if params.temperature <= 0.0 && mask.is_none() {
328            if logits.len() == 1 {
329                return logits[0] as usize;
330            }
331            let mut scores = logits.to_vec();
332            apply_history_penalties(&mut scores, params, history);
333            return argmax(&scores);
334        }
335
336        let mut scores: Vec<f32> = logits.to_vec();
337        apply_history_penalties(&mut scores, params, history);
338
339        if let Some(m) = mask.as_mut() {
340            m(&mut scores);
341        }
342
343        if params.temperature <= 0.0 {
344            if scores.len() == 1 {
345                return scores[0] as usize;
346            }
347            return argmax(&scores);
348        }
349
350        let probs = filtered_distribution(scores, params);
351        self.sample_from(&probs)
352    }
353
354    /// A uniform draw in `[0.0, 1.0)`.
355    ///
356    /// Exposed because speculative decoding's accept test is a coin
357    /// flip against `p_target(x) / p_draft(x)` rather than a draw from
358    /// a distribution, and it must come off the same seeded stream as
359    /// every other draw in the run or a "reproducible given a seed"
360    /// generation stops being reproducible.
361    pub fn uniform(&mut self) -> f32 {
362        self.next_f32()
363    }
364
365    /// Draws one index from an already-normalised distribution.
366    ///
367    /// Split out of [`Self::sample_with_mask`] so speculative decoding
368    /// can sample from a distribution it had to compute anyway (the
369    /// rejection rule needs `p_target` itself, not just a draw from it)
370    /// and still go through *exactly* the same draw as ordinary
371    /// sampling. Two separate copies of this loop would be two chances
372    /// to be subtly non-lossless.
373    pub fn sample_from(&mut self, probs: &[f32]) -> usize {
374        let draw = self.next_f32();
375        let mut cumulative = 0.0f32;
376        for (i, &p) in probs.iter().enumerate() {
377            cumulative += p;
378            if draw < cumulative {
379                return i;
380            }
381        }
382        // Floating-point rounding may leave `draw` fractionally above
383        // the final cumulative sum; the last nonzero-probability token
384        // is the correct fallback, not index 0.
385        probs
386            .iter()
387            .enumerate()
388            .rev()
389            .find(|&(_, &p)| p > 0.0)
390            .map(|(i, _)| i)
391            .unwrap_or(0)
392    }
393}
394
395/// The **exact** distribution [`Sampler::sample`] draws from for these
396/// logits, params and history: penalties applied over the
397/// `penalty_last_n` window, then top-k, top-p and min-p, then
398/// temperature, renormalised to sum to 1.
399///
400/// That is llama.cpp's chain order, and it is the order
401/// `filtered_distribution` runs -- **temperature last**, not first.
402/// This comment used to say "temperature divided in, top-k and top-p
403/// filtered", which described the pre-2026-09-01 pipeline and omitted
404/// min-p entirely.
405///
406/// This is what makes lossless speculative verification possible. The
407/// speculative-sampling rejection rule compares `p_target(x)` against
408/// the draft's `q(x)`, and "the target's probability" is meaningless
409/// unless it is the probability the *configured sampler* would actually
410/// have used -- a rule that compared against the raw softmax while the
411/// server sampled with `top_p = 0.9` would be lossless with respect to
412/// a model nobody is running.
413///
414/// Greedy (`temperature <= 0.0`) is a distribution too: the point mass
415/// on the argmax. Returning it as one rather than as a special case is
416/// why the same verification code is correct at every temperature.
417pub fn sampling_distribution(
418    logits: &[f32],
419    params: &SamplingParams,
420    history: PenaltyWindow<'_>,
421) -> Vec<f32> {
422    let mut scores = logits.to_vec();
423    apply_history_penalties(&mut scores, params, history);
424    if params.temperature <= 0.0 {
425        let mut probs = vec![0.0f32; scores.len()];
426        if let Some(p) = probs.get_mut(argmax(&scores)) {
427            *p = 1.0;
428        }
429        return probs;
430    }
431    filtered_distribution(scores, params)
432}
433
434/// Shared tail of [`Sampler::sample_with_mask`] and
435/// [`sampling_distribution`]: run the already-penalised `scores` through
436/// llama.cpp's sampler chain and return the resulting full-vocabulary
437/// distribution.
438///
439/// # Order, and why it is a specification
440///
441/// llama.cpp's default chain is `penalties, dry, top_n_sigma, top_k,
442/// typical_p, top_p, min_p, xtc, temperature` (`common/common.h:259-269`,
443/// consumed by `common/sampling.cpp:349-397`). The penalties already ran
444/// in [`apply_history_penalties`]; this function is the rest of it, in
445/// that order, and **temperature is last**.
446///
447/// ferrox used to divide by the temperature FIRST and filter afterwards.
448/// That is not a reordering of independent steps. Top-p selects the
449/// smallest set of candidates whose probabilities sum to `p`, and
450/// temperature changes those probabilities: a high temperature flattens
451/// the distribution so the nucleus grows, a low one sharpens it so the
452/// nucleus shrinks. Min-p compares each candidate's logit against
453/// `max + ln(p)`, and temperature scales exactly the gap being compared.
454/// Filtering before scaling and filtering after scaling therefore keep
455/// DIFFERENT candidate sets for the same flags.
456///
457/// Both callers go through here rather than each running their own
458/// chain, because a difference between the two is exactly the kind of
459/// silent non-losslessness speculative verification is supposed to rule
460/// out.
461///
462/// The filters themselves live in [`crate::sampler_chain`], which models
463/// the shrinking candidate list llama.cpp passes down the chain --
464/// including the renormalisation between steps that a keep-mask cannot
465/// express. See that module's header.
466///
467/// # The order is now the caller's
468///
469/// `params.sampler_order` says which steps run and in what sequence,
470/// which is llama.cpp's `--samplers`. It DEFAULTS to the sequence
471/// written out above, so a caller that never sets it gets exactly the
472/// chain this function used to hardcode -- asserted bit-for-bit by
473/// [`tests::the_default_order_is_the_chain_ferrox_already_ran`].
474///
475/// The `match` is exhaustive over [`ChainStep`] with no `..`: a step
476/// added to the order's vocabulary stops this compiling until it has
477/// something to run. And because [`SamplerOrder`] can only be built out
478/// of steps ferrox implements, there is no arm here that means "asked
479/// for, silently not done".
480fn filtered_distribution(scores: Vec<f32>, params: &SamplingParams) -> Vec<f32> {
481    let vocab = scores.len();
482    let mut candidates = Candidates::new(&scores);
483    for &step in params.sampler_order.steps() {
484        match step {
485            // Already applied to `scores`, before the candidate list
486            // existed. `SamplerOrder` refuses a `penalties` that is not
487            // first precisely so that this is the same position the
488            // caller asked for; see `SamplerOrderError::PenaltiesNotFirst`.
489            ChainStep::Penalties => {}
490            ChainStep::TopK => candidates.top_k(params.top_k),
491            ChainStep::TopP => candidates.top_p(params.top_p),
492            ChainStep::MinP => candidates.min_p(params.min_p),
493            ChainStep::Temperature => candidates.temperature(params.temperature),
494        }
495    }
496    candidates.into_distribution(vocab)
497}
498
499/// Penalise tokens that already appear in `history`, once each.
500///
501/// `history` is a [`PenaltyWindow`], so "already appear" includes the
502/// PROMPT. That is llama.cpp's rule and the module docs of
503/// [`crate::penalty_window`] carry the upstream lines; before it, every
504/// caller in this workspace picked its own slice and four of the five
505/// picked differently.
506///
507/// ONCE EACH is the whole subtlety, and ferrox used to get it wrong.
508/// llama.cpp walks the CANDIDATE list and looks each candidate up in a
509/// count map (`llama-sampler.cpp:2735-2756`), so a token repeated `n`
510/// times is divided by `penalty_repeat` exactly once. ferrox walked the
511/// HISTORY, so the same token was divided `n` times and the effective
512/// penalty was `penalty^n`.
513///
514/// That was live on every `ferrox run`: `--repeat-penalty` defaults to
515/// 1.1, so a token seen five times was penalised 1.61x rather than
516/// 1.1x, and the divergence grew with the length of the output.
517///
518/// The sign convention is llama.cpp's and its comment explains it:
519/// dividing alone would make tokens with NEGATIVE logits more likely,
520/// so negatives are multiplied instead.
521///
522/// A chain that does not name `penalties` does not penalise. llama.cpp
523/// reads an omitted sampler as "do not run it", and this function is the
524/// one place the penalties happen -- on the greedy path as well as the
525/// sampled one -- so the check belongs here rather than beside the
526/// candidate list, where the greedy path would never see it.
527fn apply_history_penalties(
528    scores: &mut [f32],
529    params: &SamplingParams,
530    history: PenaltyWindow<'_>,
531) {
532    if !params.sampler_order.has_penalties() {
533        return;
534    }
535    if params.repetition_penalty == 1.0
536        && params.presence_penalty == 0.0
537        && params.frequency_penalty == 0.0
538    {
539        return;
540    }
541    // Only the last `penalty_last_n`, as llama.cpp's ring buffer does.
542    if params.penalty_last_n == 0 {
543        return;
544    }
545    let mut counts = std::collections::HashMap::<usize, usize>::new();
546    for tok in history.recent(params.penalty_last_n) {
547        *counts.entry(tok).or_insert(0) += 1;
548    }
549    for (tok, count) in counts {
550        let Some(s) = scores.get_mut(tok) else {
551            continue;
552        };
553        if params.repetition_penalty != 1.0 {
554            *s = if *s > 0.0 {
555                *s / params.repetition_penalty
556            } else {
557                *s * params.repetition_penalty
558            };
559        }
560        *s -= params.frequency_penalty * count as f32;
561        *s -= params.presence_penalty;
562    }
563}
564
565fn argmax(logits: &[f32]) -> usize {
566    logits
567        .iter()
568        .enumerate()
569        .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
570        .map(|(i, _)| i)
571        .unwrap_or(0)
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use crate::sampler_order::SamplerOrder;
578
579    /// A deterministic, uninteresting-on-purpose logit vector: no ties,
580    /// a wide dynamic range, and a few negatives so the penalty's sign
581    /// convention is exercised.
582    fn spread_logits(vocab: usize) -> Vec<f32> {
583        (0..vocab)
584            .map(|i| ((i as f32 * 12.9898).sin() * 43_758.547).fract() * 8.0 - 3.0)
585            .collect()
586    }
587
588    /// The chain `filtered_distribution` ran BEFORE the order became a
589    /// parameter, written out by hand.
590    ///
591    /// Deliberately not built from `SamplerOrder`: a reference that read
592    /// the order it is supposed to be pinning would agree with any
593    /// reordering, which is the shape of test that proves nothing.
594    fn the_chain_ferrox_used_to_hardcode(
595        logits: &[f32],
596        params: &SamplingParams,
597        history: PenaltyWindow<'_>,
598    ) -> Vec<f32> {
599        let mut scores = logits.to_vec();
600        apply_history_penalties(&mut scores, params, history);
601        let vocab = scores.len();
602        let mut candidates = Candidates::new(&scores);
603        candidates.top_k(params.top_k);
604        candidates.top_p(params.top_p);
605        candidates.min_p(params.min_p);
606        candidates.temperature(params.temperature);
607        candidates.into_distribution(vocab)
608    }
609
610    /// **A run that does not ask for an order samples exactly what it
611    /// always did.** Bit-for-bit, against the chain written out by hand
612    /// rather than read back off `SamplerOrder`.
613    ///
614    /// This is the assertion that makes `--samplers` safe to add at all.
615    /// The order is not a reordering of independent steps: each filter
616    /// renormalises over the survivors of the last, so a default that
617    /// drifted by one position would change every generation on every
618    /// model, silently, with every other test in this file still green.
619    ///
620    /// Swap any two entries of `sampler_order::DEFAULT_STEPS` and this
621    /// goes red.
622    #[test]
623    fn the_default_order_is_the_chain_ferrox_already_ran() {
624        let logits = spread_logits(64);
625        let prompt = [3usize, 9, 17, 9];
626        let generated = [9usize, 40, 3];
627        // Every filter switched on, and all three penalties, so there is
628        // something for a misplaced step to change.
629        // Every adjacent pair of the default chain has to be
630        // DISTINGUISHED by at least one row, or the assertion below
631        // passes for a chain in the wrong order. `top_k 5` with
632        // `top_p 0.9` separates top-k from top-p (top-p over the whole
633        // vocabulary keeps far more than five, so which runs first
634        // decides the answer); `min_p 0.2` separates top-p from min-p;
635        // any temperature away from 1.0 separates min-p from
636        // temperature.
637        for (temperature, top_k, top_p, min_p) in [
638            (0.8f32, 5usize, 0.9f32, 0.05f32),
639            (4.0, 3, 0.85, 0.2),
640            (0.2, 8, 0.95, 0.1),
641            (1.0, 40, 0.5, 0.02),
642            (0.8, 40, 0.95, 0.05),
643        ] {
644            let params = SamplingParams {
645                temperature,
646                top_k,
647                top_p,
648                min_p,
649                repetition_penalty: 1.1,
650                presence_penalty: 0.3,
651                frequency_penalty: 0.4,
652                ..SamplingParams::default()
653            };
654            let window = || PenaltyWindow::new(&prompt, &generated);
655            let expected = the_chain_ferrox_used_to_hardcode(&logits, &params, window());
656            let actual = sampling_distribution(&logits, &params, window());
657            for (i, (a, e)) in actual.iter().zip(expected.iter()).enumerate() {
658                assert_eq!(
659                    a.to_bits(),
660                    e.to_bits(),
661                    "token {i} at temp {temperature}, top_k {top_k}, top_p {top_p}, \
662                     min_p {min_p}: the default order sampled {a} where the chain ferrox \
663                     already ran gives {e}"
664                );
665            }
666        }
667    }
668
669    /// And the same at the token level: the ids a seeded `Sampler` draws
670    /// under `SamplingParams::default()` are the ids it draws when the
671    /// caller spells out the default chain, so the flag's default value
672    /// and the struct's default are one chain and not two.
673    #[test]
674    fn spelling_out_the_default_chain_draws_the_same_tokens() {
675        let logits = spread_logits(48);
676        let base = SamplingParams {
677            temperature: 0.8,
678            top_k: 40,
679            top_p: 0.95,
680            min_p: 0.05,
681            repetition_penalty: 1.1,
682            ..SamplingParams::default()
683        };
684        let spelled = SamplingParams {
685            sampler_order: "penalties;top_k;top_p;min_p;temperature"
686                .parse::<SamplerOrder>()
687                .expect("the default chain must parse"),
688            ..base.clone()
689        };
690        let draw = |params: &SamplingParams| {
691            let mut sampler = Sampler::new(0xFE0);
692            let mut generated: Vec<usize> = Vec::new();
693            for _ in 0..64 {
694                let next = sampler.sample(&logits, params, PenaltyWindow::new(&[7], &generated));
695                generated.push(next);
696            }
697            generated
698        };
699        assert_eq!(draw(&base), draw(&spelled));
700    }
701
702    /// **The flag does something.** A chain that runs the temperature
703    /// before top-p keeps a different candidate set than the default,
704    /// which is the whole reason the order is worth exposing -- and the
705    /// reason getting it wrong is a silent quality regression rather
706    /// than an error.
707    ///
708    /// A hot temperature flattens the distribution, so a top-p applied
709    /// after it sums smaller probabilities and reaches `p` later,
710    /// keeping MORE candidates.
711    #[test]
712    fn running_the_temperature_first_keeps_a_different_candidate_set() {
713        let logits = vec![6.0f32, 4.0, 2.0, 0.0, -2.0, -4.0];
714        let params = |order: &str| SamplingParams {
715            temperature: 8.0,
716            top_p: 0.9,
717            top_k: 0,
718            min_p: 0.0,
719            sampler_order: order.parse().expect("chain"),
720            ..SamplingParams::default()
721        };
722        let support = |order: &str| -> Vec<bool> {
723            sampling_distribution(&logits, &params(order), PenaltyWindow::new(&[], &[]))
724                .iter()
725                .map(|&p| p > 0.0)
726                .collect()
727        };
728
729        let default = support("penalties;top_k;top_p;min_p;temperature");
730        let temperature_first = support("penalties;temperature;top_k;top_p;min_p");
731        assert_ne!(
732            default, temperature_first,
733            "reordering the chain must change which candidates survive, \
734             or the flag is decorative"
735        );
736        assert!(
737            temperature_first.iter().filter(|&&k| k).count()
738                > default.iter().filter(|&&k| k).count(),
739            "temp 8.0 flattens the distribution, so a later top-p keeps more: \
740             default={default:?} temperature_first={temperature_first:?}"
741        );
742    }
743
744    /// A sampler left OUT of the chain does not run, even though its
745    /// knob is set -- llama.cpp reads an omitted sampler as "do not run
746    /// it", and a chain that ran it anyway would be honouring a request
747    /// nobody made.
748    #[test]
749    fn a_sampler_absent_from_the_chain_does_not_filter() {
750        let logits = vec![4.0f32, 3.0, 2.0, 1.0];
751        let with_min_p = SamplingParams {
752            temperature: 1.0,
753            min_p: 0.2,
754            ..SamplingParams::default()
755        };
756        let survivors = |params: &SamplingParams| {
757            sampling_distribution(&logits, params, PenaltyWindow::new(&[], &[]))
758                .iter()
759                .filter(|&&p| p > 0.0)
760                .count()
761        };
762        // The default chain runs min-p: 4 + ln(0.2) = 2.3905 keeps two.
763        assert_eq!(survivors(&with_min_p), 2);
764
765        let without_min_p = SamplingParams {
766            sampler_order: "penalties;top_k;top_p;temperature".parse().expect("chain"),
767            ..with_min_p.clone()
768        };
769        assert_eq!(
770            survivors(&without_min_p),
771            4,
772            "`min_p` is set but not in the chain, so nothing should truncate"
773        );
774    }
775
776    /// Leaving `penalties` out of the chain disables the penalties, on
777    /// the SAMPLED path and on the greedy one.
778    ///
779    /// The greedy half is the one that would have been missed: the
780    /// penalties are applied before the candidate list exists, so a
781    /// check placed beside the chain would never run at `temp <= 0`,
782    /// and `--samplers` without `penalties` would still have penalised.
783    #[test]
784    fn a_chain_without_penalties_does_not_penalise_on_either_path() {
785        // Token 0 leads token 1 by less than the 1.1 penalty.
786        let logits = vec![4.0f32, 3.9];
787        let history = || PenaltyWindow::new(&[0], &[]);
788        let greedy = SamplingParams {
789            temperature: 0.0,
790            repetition_penalty: 1.1,
791            ..SamplingParams::default()
792        };
793        let mut sampler = Sampler::new(1);
794        assert_eq!(
795            sampler.sample(&logits, &greedy, history()),
796            1,
797            "the default chain penalises the prompt token"
798        );
799
800        let unpenalised = SamplingParams {
801            sampler_order: "top_k;top_p;min_p;temperature".parse().expect("chain"),
802            ..greedy.clone()
803        };
804        assert!(!unpenalised.sampler_order.has_penalties());
805        assert_eq!(
806            sampler.sample(&logits, &unpenalised, history()),
807            0,
808            "`penalties` is not in the chain, so the argmax must stand"
809        );
810
811        // And on the sampled path, where the whole distribution is
812        // visible rather than one argmax.
813        let sampled = SamplingParams {
814            temperature: 1.0,
815            ..unpenalised
816        };
817        let with = SamplingParams {
818            sampler_order: SamplerOrder::default(),
819            ..sampled.clone()
820        };
821        assert_ne!(
822            sampling_distribution(&logits, &sampled, history()),
823            sampling_distribution(&logits, &with, history())
824        );
825    }
826
827    /// A token that has only ever appeared in the PROMPT is penalised
828    /// on the very first generated position, and that changes which
829    /// token is sampled.
830    ///
831    /// This is the divergence issue #55 reported. llama.cpp seeds its
832    /// penalties sampler with every prompt token before drawing
833    /// anything (`tools/server/server-context.cpp:386-390`,
834    /// `tools/completion/completion.cpp:730-736`); ferrox's decode
835    /// loops handed the sampler the generated tokens alone, so the same
836    /// checkpoint, flags and prompt could produce different text at the
837    /// default `--repeat-penalty 1.1`.
838    ///
839    /// Asserted on the SAMPLED TOKEN rather than on the window's
840    /// contents: a test that only checked the slice could not tell the
841    /// window being applied to the wrong distribution from the window
842    /// being wrong. Drop `prompt` from `PenaltyWindow::recent` and this
843    /// goes red -- the second assertion returns 0.
844    #[test]
845    fn a_prompt_token_is_penalised_before_it_is_ever_generated() {
846        let params = SamplingParams {
847            // Greedy, so the assertion is on the chosen id and not on a
848            // draw. Everything below is arithmetic, not sampling.
849            temperature: 0.0,
850            repetition_penalty: 1.1,
851            ..SamplingParams::default()
852        };
853        // Token 0 leads token 1 by less than the 1.1 penalty: 4.0 / 1.1
854        // = 3.636, which is below 3.9.
855        let logits = vec![4.0f32, 3.9];
856        let mut sampler = Sampler::new(1);
857
858        assert_eq!(
859            sampler.sample(&logits, &params, PenaltyWindow::new(&[], &[])),
860            0,
861            "with nothing behind it the argmax wins"
862        );
863        assert_eq!(
864            sampler.sample(&logits, &params, PenaltyWindow::new(&[0], &[])),
865            1,
866            "token 0 is in the prompt, so llama.cpp penalises it here"
867        );
868        // And a window that reaches back past the prompt is the same
869        // answer, which is what makes the two halves one sequence.
870        assert_eq!(
871            sampler.sample(&logits, &params, PenaltyWindow::new(&[9, 0], &[8])),
872            1
873        );
874    }
875
876    /// `penalty_last_n` counts across the prompt/generated seam, so a
877    /// prompt token falls OUT of the window once enough tokens have
878    /// been generated after it -- and the sampled token moves back.
879    ///
880    /// A window that added the whole prompt to the last N generated
881    /// tokens would keep penalising token 0 forever and this would stay
882    /// at 1.
883    #[test]
884    fn a_prompt_token_leaves_the_window_once_the_generation_outgrows_it() {
885        let params = SamplingParams {
886            temperature: 0.0,
887            repetition_penalty: 1.1,
888            penalty_last_n: 2,
889            ..SamplingParams::default()
890        };
891        let logits = vec![4.0f32, 3.9];
892        let mut sampler = Sampler::new(1);
893
894        // Prompt token 0, one token generated: the window is [0, 5] and
895        // token 0 is still penalised.
896        assert_eq!(
897            sampler.sample(&logits, &params, PenaltyWindow::new(&[0], &[5])),
898            1
899        );
900        // Two generated: the window is [5, 6] and token 0 is clear.
901        assert_eq!(
902            sampler.sample(&logits, &params, PenaltyWindow::new(&[0], &[5, 6])),
903            0
904        );
905    }
906
907    /// The repetition penalty is applied ONCE per token, however many
908    /// times that token appears in the history.
909    ///
910    /// ferrox walked the history and divided once per OCCURRENCE, so the
911    /// effective penalty was `penalty^n`. llama.cpp walks the candidates
912    /// and looks each up in a count map, so it is `penalty` flat
913    /// (`llama-sampler.cpp:2735-2756`).
914    ///
915    /// Live on every `ferrox run`: `--repeat-penalty` defaults to 1.1,
916    /// so a token seen five times was penalised 1.61x, and the
917    /// divergence grew with the length of the output. Twenty-four
918    /// sampling tests passed with the bug in place, which is why this
919    /// one exists.
920    #[test]
921    fn the_repetition_penalty_does_not_compound_with_repeats() {
922        let params = SamplingParams {
923            temperature: 1.0,
924            top_p: 1.0,
925            top_k: 0,
926            repetition_penalty: 2.0,
927            ..SamplingParams::default()
928        };
929        let logits = vec![4.0f32, 1.0, 1.0];
930
931        // Token 0 appears five times. Penalised once, its score is 2.0;
932        // compounded it would be 4 / 2^5 = 0.125.
933        let mut scores = logits.clone();
934        apply_history_penalties(
935            &mut scores,
936            &params,
937            PenaltyWindow::new(&[], &[0, 0, 0, 0, 0]),
938        );
939        assert!(
940            (scores[0] - 2.0).abs() < 1e-6,
941            "expected one division (2.0), got {} -- {} would be 2^5",
942            scores[0],
943            4.0f32 / 32.0
944        );
945
946        // And once really is once: one occurrence and five occurrences
947        // must land on the same score, or the count still leaks in.
948        let mut once = logits.clone();
949        apply_history_penalties(&mut once, &params, PenaltyWindow::new(&[], &[0]));
950        assert_eq!(once[0].to_bits(), scores[0].to_bits());
951
952        // A NEGATIVE logit is multiplied rather than divided, or the
953        // penalty would make it more likely -- llama.cpp's own comment.
954        let mut negative = vec![-4.0f32];
955        apply_history_penalties(&mut negative, &params, PenaltyWindow::new(&[], &[0, 0, 0]));
956        assert!((negative[0] + 8.0).abs() < 1e-6, "got {}", negative[0]);
957    }
958
959    /// The penalties look at the last `penalty_last_n` tokens, not the
960    /// whole history.
961    ///
962    /// llama.cpp keeps a ring buffer of `penalty_last_n` (default 64,
963    /// `common/common.h:238`); ferrox scanned everything generated so
964    /// far. On a long generation that is a steadily growing set of
965    /// penalised tokens against llama.cpp's fixed 64 -- the divergence
966    /// grows with output length, which is when a repetition penalty
967    /// matters most.
968    #[test]
969    fn the_penalties_only_see_the_last_n_tokens() {
970        let params = SamplingParams {
971            repetition_penalty: 2.0,
972            penalty_last_n: 2,
973            ..SamplingParams::default()
974        };
975        let mut scores = vec![8.0f32, 8.0, 8.0];
976        // Token 0 fell out of the window; tokens 1 and 2 are in it.
977        apply_history_penalties(&mut scores, &params, PenaltyWindow::new(&[], &[0, 1, 2]));
978        assert_eq!(
979            scores[0].to_bits(),
980            8.0f32.to_bits(),
981            "token 0 is outside the window"
982        );
983        assert!((scores[1] - 4.0).abs() < 1e-6, "got {}", scores[1]);
984        assert!((scores[2] - 4.0).abs() < 1e-6, "got {}", scores[2]);
985
986        // `0` disables the penalties outright, as llama.cpp documents.
987        let off = SamplingParams {
988            penalty_last_n: 0,
989            ..params
990        };
991        let mut untouched = vec![8.0f32; 3];
992        apply_history_penalties(&mut untouched, &off, PenaltyWindow::new(&[], &[0, 1, 2]));
993        assert_eq!(untouched, vec![8.0f32; 3]);
994
995        // A window longer than the history is not an overflow.
996        let wide = SamplingParams {
997            penalty_last_n: 1000,
998            ..params
999        };
1000        let mut short = vec![8.0f32];
1001        apply_history_penalties(&mut short, &wide, PenaltyWindow::new(&[], &[0]));
1002        assert!((short[0] - 4.0).abs() < 1e-6);
1003    }
1004
1005    /// Frequency penalty still scales with the count, while the
1006    /// repetition penalty does not.
1007    ///
1008    /// Both live in the same loop, so a fix that made the repetition
1009    /// penalty flat by dropping the counts would break this one.
1010    #[test]
1011    fn the_frequency_penalty_still_counts_repeats() {
1012        let params = SamplingParams {
1013            frequency_penalty: 0.5,
1014            presence_penalty: 0.25,
1015            ..SamplingParams::default()
1016        };
1017        let mut scores = vec![10.0f32];
1018        apply_history_penalties(&mut scores, &params, PenaltyWindow::new(&[], &[0, 0, 0, 0]));
1019        // 10 - 0.5*4 - 0.25 = 7.75
1020        assert!((scores[0] - 7.75).abs() < 1e-6, "got {}", scores[0]);
1021    }
1022
1023    /// Top-p cuts the UNSCALED distribution; the temperature reshapes
1024    /// only the survivors.
1025    ///
1026    /// llama.cpp's default chain runs temperature LAST
1027    /// (`common/common.h:259-269`); ferrox divided first and filtered
1028    /// afterwards. Not an innocuous reordering: temperature changes the
1029    /// probabilities top-p sums over, so a high temperature flattens the
1030    /// distribution and grows the nucleus. The two orders keep different
1031    /// candidate sets for identical flags.
1032    #[test]
1033    fn temperature_does_not_change_which_candidates_top_p_keeps() {
1034        let logits = vec![3.0f32, 2.0, 1.0, 0.0];
1035        let at = |temperature: f32| -> Vec<bool> {
1036            let params = SamplingParams {
1037                temperature,
1038                top_p: 0.9,
1039                top_k: 0,
1040                ..SamplingParams::default()
1041            };
1042            sampling_distribution(&logits, &params, PenaltyWindow::new(&[], &[]))
1043                .iter()
1044                .map(|&p| p > 0.0)
1045                .collect()
1046        };
1047
1048        let cold = at(0.5);
1049        let hot = at(4.0);
1050        assert_eq!(
1051            cold, hot,
1052            "the surviving set must not depend on the temperature: \
1053             cold={cold:?} hot={hot:?}"
1054        );
1055        // And the cut must actually bite, or the equality above is
1056        // satisfied by keeping everything.
1057        assert!(
1058            cold.iter().any(|&k| !k),
1059            "top_p = 0.9 must drop at least one of these four candidates"
1060        );
1061    }
1062
1063    /// min-p truncates, and it truncates on llama.cpp's threshold.
1064    ///
1065    /// llama.cpp enables min-p **by default** at 0.05
1066    /// (`common/common.h:231`), so until this existed ferrox could not
1067    /// reproduce llama.cpp's own out-of-the-box output on any prompt --
1068    /// a parity gap, not a missing feature.
1069    ///
1070    /// Logits `[4, 3, 2, 1]` at `min_p = 0.2`: the threshold is
1071    /// `4 + ln(0.2) = 2.3905`, so exactly the candidates at 4 and 3
1072    /// survive. Arithmetic done by hand from
1073    /// `src/llama-sampler.cpp:1556`, not read back off the code.
1074    #[test]
1075    fn min_p_truncates_at_ln_p_below_the_top_logit() {
1076        let logits = vec![4.0f32, 3.0, 2.0, 1.0];
1077        let params = SamplingParams {
1078            temperature: 1.0,
1079            min_p: 0.2,
1080            ..SamplingParams::default()
1081        };
1082        let probs = sampling_distribution(&logits, &params, PenaltyWindow::new(&[], &[]));
1083        assert!(probs[0] > 0.0 && probs[1] > 0.0);
1084        assert_eq!(probs[2], 0.0, "2.0 is below 4 + ln(0.2) = 2.3905");
1085        assert_eq!(probs[3], 0.0);
1086        assert!((probs.iter().sum::<f32>() - 1.0).abs() < 1e-6);
1087
1088        // The two survivors are renormalised against each other:
1089        // e^4 / (e^4 + e^3) = 0.7311.
1090        assert!((probs[0] - 0.731_059).abs() < 1e-5, "got {}", probs[0]);
1091
1092        // 0.0 disables it, which is ferrox's struct default -- adding
1093        // min-p must not change any existing caller's distribution.
1094        let off = SamplingParams {
1095            min_p: 0.0,
1096            ..params.clone()
1097        };
1098        let unfiltered = sampling_distribution(&logits, &off, PenaltyWindow::new(&[], &[]));
1099        assert!(unfiltered.iter().all(|&p| p > 0.0));
1100    }
1101
1102    /// min-p runs BEFORE the temperature, so the set it keeps does not
1103    /// depend on `--temp`.
1104    ///
1105    /// This is the same trap as E4 and it bites harder here. min-p's
1106    /// test is `logit_i >= logit_max + ln(p)`, and temperature divides
1107    /// **both** logits, so it scales the very gap being compared against
1108    /// a fixed `ln(p)`. On these logits at `min_p = 0.2`, running min-p
1109    /// after a temperature of 0.5 would keep one candidate and after 2.0
1110    /// would keep all four; llama.cpp keeps two at every temperature
1111    /// (`common/common.h:259-269` puts `MIN_P` before `TEMPERATURE`).
1112    ///
1113    /// Move `candidates.min_p(..)` after `candidates.temperature(..)` in
1114    /// `filtered_distribution` and this goes red.
1115    #[test]
1116    fn temperature_does_not_change_which_candidates_min_p_keeps() {
1117        let logits = vec![3.0f32, 2.0, 1.0, 0.0];
1118        let survivors = |temperature: f32| -> Vec<bool> {
1119            let params = SamplingParams {
1120                temperature,
1121                min_p: 0.2,
1122                ..SamplingParams::default()
1123            };
1124            sampling_distribution(&logits, &params, PenaltyWindow::new(&[], &[]))
1125                .iter()
1126                .map(|&p| p > 0.0)
1127                .collect()
1128        };
1129
1130        let cold = survivors(0.5);
1131        let warm = survivors(1.0);
1132        let hot = survivors(2.0);
1133        assert_eq!(cold, warm, "cold={cold:?} warm={warm:?}");
1134        assert_eq!(warm, hot, "warm={warm:?} hot={hot:?}");
1135        // 3 + ln(0.2) = 1.3905, so exactly the 3.0 and 2.0 candidates.
1136        assert_eq!(warm, vec![true, true, false, false]);
1137    }
1138
1139    /// min-p sits AFTER top-p in the chain, and both may bite on the
1140    /// same call.
1141    ///
1142    /// `top_p = 0.95` on this distribution keeps three candidates
1143    /// (0.6337 + 0.2331 + 0.0857 = 0.9525); min-p at 0.2 then drops the
1144    /// third, whose probability is 0.135 of the top one. Getting only
1145    /// one of the two filters gives a different answer either way, so
1146    /// this fails if either is dropped or if min-p is skipped when top-p
1147    /// already truncated.
1148    #[test]
1149    fn top_p_and_min_p_both_apply() {
1150        let logits = vec![3.0f32, 2.0, 1.0, 0.0];
1151        let params = SamplingParams {
1152            temperature: 1.0,
1153            top_p: 0.95,
1154            min_p: 0.2,
1155            ..SamplingParams::default()
1156        };
1157        let probs = sampling_distribution(&logits, &params, PenaltyWindow::new(&[], &[]));
1158        assert_eq!(
1159            probs.iter().map(|&p| p > 0.0).collect::<Vec<_>>(),
1160            vec![true, true, false, false]
1161        );
1162
1163        // top-p alone keeps three; min-p alone also keeps two here, so
1164        // pin the top-p-only case to prove the two filters are distinct
1165        // and that this test is not satisfied by min-p doing all the
1166        // work.
1167        let top_p_only = SamplingParams {
1168            min_p: 0.0,
1169            ..params.clone()
1170        };
1171        assert_eq!(
1172            sampling_distribution(&logits, &top_p_only, PenaltyWindow::new(&[], &[]))
1173                .iter()
1174                .filter(|&&p| p > 0.0)
1175                .count(),
1176            3
1177        );
1178    }
1179
1180    #[test]
1181    fn temperature_zero_accepts_precomputed_argmax_singleton() {
1182        let mut sampler = Sampler::new(1);
1183        let params = SamplingParams::default();
1184        assert_eq!(
1185            sampler.sample(&[42.0], &params, PenaltyWindow::new(&[], &[])),
1186            42
1187        );
1188        // Non-greedy must not treat a singleton as a token id.
1189        let sampled = SamplingParams {
1190            temperature: 0.8,
1191            ..SamplingParams::default()
1192        };
1193        // Softmax of a single logit → only token 0 is eligible.
1194        assert_eq!(
1195            sampler.sample(&[42.0], &sampled, PenaltyWindow::new(&[], &[])),
1196            0
1197        );
1198    }
1199
1200    #[test]
1201    fn temperature_zero_is_deterministic_greedy_argmax() {
1202        let logits = vec![0.1, 0.9, 0.3, -0.2];
1203        let params = SamplingParams::default();
1204        let mut sampler = Sampler::new(42);
1205        assert_eq!(
1206            sampler.sample(&logits, &params, PenaltyWindow::new(&[], &[])),
1207            1
1208        );
1209        // Must be deterministic regardless of RNG state advancing.
1210        assert_eq!(
1211            sampler.sample(&logits, &params, PenaltyWindow::new(&[], &[])),
1212            1
1213        );
1214    }
1215
1216    #[test]
1217    fn high_temperature_can_pick_a_non_argmax_token_over_many_draws() {
1218        let logits = vec![1.0, 1.0, 1.0, 1.0];
1219        let params = SamplingParams {
1220            temperature: 1.0,
1221            ..SamplingParams::default()
1222        };
1223        let mut sampler = Sampler::new(7);
1224        let mut seen = std::collections::HashSet::new();
1225        for _ in 0..200 {
1226            seen.insert(sampler.sample(&logits, &params, PenaltyWindow::new(&[], &[])));
1227        }
1228        assert!(
1229            seen.len() > 1,
1230            "uniform logits at temperature=1.0 must produce more than one distinct token across 200 draws"
1231        );
1232    }
1233
1234    #[test]
1235    fn top_k_one_is_equivalent_to_greedy() {
1236        let logits = vec![0.1, 0.9, 0.3, -0.2];
1237        let params = SamplingParams {
1238            temperature: 1.0,
1239            top_k: 1,
1240            ..SamplingParams::default()
1241        };
1242        let mut sampler = Sampler::new(123);
1243        for _ in 0..20 {
1244            assert_eq!(
1245                sampler.sample(&logits, &params, PenaltyWindow::new(&[], &[])),
1246                1
1247            );
1248        }
1249    }
1250
1251    #[test]
1252    fn top_p_near_zero_is_equivalent_to_greedy() {
1253        let logits = vec![0.1, 5.0, 0.3, -0.2];
1254        let params = SamplingParams {
1255            temperature: 1.0,
1256            top_p: 0.001,
1257            ..SamplingParams::default()
1258        };
1259        let mut sampler = Sampler::new(9);
1260        for _ in 0..20 {
1261            assert_eq!(
1262                sampler.sample(&logits, &params, PenaltyWindow::new(&[], &[])),
1263                1
1264            );
1265        }
1266    }
1267
1268    #[test]
1269    fn presence_and_frequency_penalties_reduce_seen_token_logits() {
1270        let logits = vec![0.0, 5.0, 0.0];
1271        let params = SamplingParams {
1272            temperature: 1.0,
1273            presence_penalty: 10.0,
1274            frequency_penalty: 0.0,
1275            ..SamplingParams::default()
1276        };
1277        let mut sampler = Sampler::new(1);
1278        let mut counts = [0usize; 3];
1279        for _ in 0..500 {
1280            counts[sampler.sample(&logits, &params, PenaltyWindow::new(&[], &[1]))] += 1;
1281        }
1282        assert!(
1283            counts[1] < 250,
1284            "presence_penalty should discourage token 1; counts={counts:?}"
1285        );
1286
1287        let params = SamplingParams {
1288            temperature: 1.0,
1289            presence_penalty: 0.0,
1290            frequency_penalty: 10.0,
1291            ..SamplingParams::default()
1292        };
1293        let mut sampler = Sampler::new(2);
1294        counts = [0; 3];
1295        for _ in 0..500 {
1296            counts[sampler.sample(&logits, &params, PenaltyWindow::new(&[], &[1, 1, 1]))] += 1;
1297        }
1298        assert!(
1299            counts[1] < 250,
1300            "frequency_penalty should discourage repeated token 1; counts={counts:?}"
1301        );
1302    }
1303
1304    #[test]
1305    fn repetition_penalty_reduces_probability_of_recently_seen_token() {
1306        let logits = vec![0.0, 5.0, 0.0];
1307        let params = SamplingParams {
1308            temperature: 1.0,
1309            repetition_penalty: 1000.0,
1310            ..SamplingParams::default()
1311        };
1312        let mut sampler = Sampler::new(3);
1313        let mut counts = [0usize; 3];
1314        for _ in 0..500 {
1315            counts[sampler.sample(&logits, &params, PenaltyWindow::new(&[], &[1]))] += 1;
1316        }
1317        assert!(
1318            counts[1] < 250,
1319            "heavily penalizing token 1 (already in history) should make it far less likely than its raw logit alone would suggest; got counts={counts:?}"
1320        );
1321    }
1322
1323    #[test]
1324    fn low_seeds_do_not_bias_the_first_draw() {
1325        // Every generation seeds a fresh `Sampler` (the server does it
1326        // per request, from the caller's `seed`), so the FIRST draw off
1327        // a freshly seeded generator is the one users actually see.
1328        // Plain xorshift64 returns its own state, so seeds 1..4000 all
1329        // produced a first draw in the bottom eighth of [0, 1) -- the
1330        // first sampled token of every seeded request came off the
1331        // bottom of the CDF.
1332        let vocab = 8;
1333        let logits = vec![0.0f32; vocab];
1334        let params = SamplingParams {
1335            temperature: 1.0,
1336            ..SamplingParams::default()
1337        };
1338        let seeds = 4_000u64;
1339        let mut counts = vec![0usize; vocab];
1340        for seed in 1..=seeds {
1341            counts[Sampler::new(seed).sample(&logits, &params, PenaltyWindow::new(&[], &[]))] += 1;
1342        }
1343        let expected = seeds as f64 / vocab as f64;
1344        for (token, &c) in counts.iter().enumerate() {
1345            assert!(
1346                (c as f64 - expected).abs() < expected * 0.25,
1347                "uniform logits: token {token} came up {c} times across {seeds} seeds, \
1348                 expected about {expected:.0} (counts={counts:?})"
1349            );
1350        }
1351    }
1352
1353    #[test]
1354    fn the_published_distribution_is_the_one_sample_actually_draws_from() {
1355        // `sampling_distribution` is load-bearing for lossless
1356        // speculative verification: if it disagreed with what `sample`
1357        // draws from, every accept/reject decision would be measured
1358        // against the wrong target. Check them against each other
1359        // empirically, with filters on so the two code paths have
1360        // something to disagree about.
1361        let logits = vec![0.4, 2.0, -1.0, 1.2, 0.9, -0.3];
1362        let params = SamplingParams {
1363            temperature: 0.8,
1364            top_p: 0.9,
1365            top_k: 4,
1366            repetition_penalty: 1.3,
1367            ..SamplingParams::default()
1368        };
1369        let history = [1usize, 4];
1370        let claimed = sampling_distribution(&logits, &params, PenaltyWindow::new(&[], &history));
1371        assert!((claimed.iter().sum::<f32>() - 1.0).abs() < 1e-5);
1372
1373        let draws = 100_000;
1374        let mut counts = vec![0usize; logits.len()];
1375        let mut sampler = Sampler::new(0xC0FFEE);
1376        for _ in 0..draws {
1377            counts[sampler.sample(&logits, &params, PenaltyWindow::new(&[], &history))] += 1;
1378        }
1379        for (i, &c) in counts.iter().enumerate() {
1380            let empirical = c as f64 / draws as f64;
1381            assert!(
1382                (empirical - claimed[i] as f64).abs() < 0.01,
1383                "token {i}: sample() draws it {empirical:.4} of the time but \
1384                 sampling_distribution claims {:.4}",
1385                claimed[i]
1386            );
1387        }
1388    }
1389
1390    #[test]
1391    fn greedy_is_published_as_a_point_mass_not_a_special_case() {
1392        let logits = vec![0.1, 0.9, 0.3, -0.2];
1393        let probs = sampling_distribution(
1394            &logits,
1395            &SamplingParams::default(),
1396            PenaltyWindow::new(&[], &[]),
1397        );
1398        assert_eq!(probs, vec![0.0, 1.0, 0.0, 0.0]);
1399        // Penalties still apply at temperature 0, so the point mass
1400        // moves with them.
1401        let penalized = sampling_distribution(
1402            &logits,
1403            &SamplingParams {
1404                repetition_penalty: 100.0,
1405                ..SamplingParams::default()
1406            },
1407            PenaltyWindow::new(&[], &[1]),
1408        );
1409        assert_eq!(penalized[1], 0.0);
1410        assert_eq!(penalized.iter().sum::<f32>(), 1.0);
1411    }
1412
1413    #[test]
1414    fn degenerate_all_zero_probability_falls_back_to_greedy() {
1415        // top_k=1 combined with a top_p that would exclude even that
1416        // one surviving token is a contradictory/degenerate
1417        // configuration; must not panic or sample index 0 blindly.
1418        let logits = vec![0.1, 0.9, 0.3, -0.2];
1419        let params = SamplingParams {
1420            temperature: 1.0,
1421            top_k: 1,
1422            top_p: 1.0,
1423            ..SamplingParams::default()
1424        };
1425        let mut sampler = Sampler::new(1);
1426        assert_eq!(
1427            sampler.sample(&logits, &params, PenaltyWindow::new(&[], &[])),
1428            1
1429        );
1430    }
1431
1432    /// Only the keys the file actually carries become a recommendation.
1433    ///
1434    /// **This test fails if an absent key is filled with a house
1435    /// default** (the naive reading, and what HF's own
1436    /// `GenerationConfig` object does): `top_p` and `top_k` would come
1437    /// back as `Some(1.0)` / `Some(0)` and would then override whatever
1438    /// the server itself defaults to, for values this checkpoint never
1439    /// expressed.
1440    #[test]
1441    fn an_absent_generation_config_key_stays_absent_rather_than_taking_a_default() {
1442        let recommended = RecommendedSampling::from_generation_config(r#"{"temperature": 0.6}"#);
1443        assert_eq!(recommended.temperature, Some(0.6));
1444        assert_eq!(recommended.top_p, None, "top_p was not in the file");
1445        assert_eq!(recommended.top_k, None, "top_k was not in the file");
1446        // An explicit JSON null is silence too (the reference's
1447        // `if val is not None`).
1448        let nulled = RecommendedSampling::from_generation_config(r#"{"top_p": null}"#);
1449        assert_eq!(nulled, RecommendedSampling::default());
1450    }
1451
1452    /// A reasoning checkpoint's full recommendation survives intact --
1453    /// the case the whole path exists for (Qwen3.5: temp 1.0, top_k 20,
1454    /// top_p 0.95).
1455    #[test]
1456    fn every_generation_config_key_present_is_recommended() {
1457        let recommended = RecommendedSampling::from_generation_config(
1458            r#"{"do_sample": true, "temperature": 1.0, "top_k": 20, "top_p": 0.95}"#,
1459        );
1460        assert_eq!(
1461            recommended,
1462            RecommendedSampling {
1463                temperature: Some(1.0),
1464                top_p: Some(0.95),
1465                top_k: Some(20),
1466            }
1467        );
1468    }
1469
1470    /// `do_sample: false` recommends greedy, expressed as temperature 0
1471    /// and *nothing else*: the top_k/top_p such a file also carries
1472    /// describe a sampler it is asking not to be used, so returning them
1473    /// would filter a distribution the model wants collapsed to its
1474    /// argmax.
1475    #[test]
1476    fn do_sample_false_recommends_greedy_and_no_other_field() {
1477        let recommended = RecommendedSampling::from_generation_config(
1478            r#"{"do_sample": false, "temperature": 0.7, "top_k": 50, "top_p": 0.9}"#,
1479        );
1480        assert_eq!(recommended.temperature, Some(0.0));
1481        assert_eq!(recommended.top_p, None);
1482        assert_eq!(recommended.top_k, None);
1483    }
1484
1485    /// A sidecar that does not parse must not be able to change how the
1486    /// model is sampled.
1487    #[test]
1488    fn a_malformed_generation_config_recommends_nothing() {
1489        for text in ["", "not json", "[1, 2, 3]", "null"] {
1490            assert!(
1491                RecommendedSampling::from_generation_config(text).is_empty(),
1492                "{text:?} must recommend nothing"
1493            );
1494        }
1495    }
1496
1497    /// Precedence: the request wins over the checkpoint, and the
1498    /// checkpoint only fills what the request left unset. An explicit
1499    /// `temperature: 0` from a client must stay reachable on a model
1500    /// that recommends 1.0.
1501    #[test]
1502    fn a_request_outranks_the_recommendation_which_outranks_the_framework_default() {
1503        let recommended = RecommendedSampling {
1504            temperature: Some(1.0),
1505            top_p: Some(0.95),
1506            top_k: Some(20),
1507        };
1508        let resolved = recommended.resolve(
1509            RequestedSampling {
1510                temperature: Some(0.0),
1511                ..RequestedSampling::default()
1512            },
1513            SamplingParams::default(),
1514        );
1515        assert_eq!(resolved.temperature, 0.0, "the request asked for greedy");
1516        assert_eq!(resolved.top_p, 0.95, "the request said nothing about top_p");
1517        assert_eq!(resolved.top_k, 20, "the request said nothing about top_k");
1518        // Penalties are never recommended, only carried through.
1519        assert_eq!(resolved.repetition_penalty, 1.0);
1520    }
1521
1522    /// A checkpoint that recommends nothing must leave ferrox's existing
1523    /// behaviour bit-identical: greedy, unfiltered, exactly
1524    /// `SamplingParams::default()`.
1525    #[test]
1526    fn a_checkpoint_that_recommends_nothing_leaves_the_framework_defaults_alone() {
1527        let resolved = RecommendedSampling::default()
1528            .resolve(RequestedSampling::default(), SamplingParams::default());
1529        let default = SamplingParams::default();
1530        assert_eq!(resolved.temperature, default.temperature);
1531        assert_eq!(resolved.top_p, default.top_p);
1532        assert_eq!(resolved.top_k, default.top_k);
1533    }
1534
1535    /// A model directory with no `generation_config.json` recommends
1536    /// nothing rather than failing: the absence of a recommendation is
1537    /// the normal case for most checkpoints.
1538    #[test]
1539    fn a_model_directory_without_a_generation_config_recommends_nothing() {
1540        let dir = std::env::temp_dir().join(format!(
1541            "ferrox_test_no_generation_config_{}",
1542            std::process::id()
1543        ));
1544        std::fs::create_dir_all(&dir).unwrap();
1545        assert!(RecommendedSampling::from_model_dir(&dir).is_empty());
1546        std::fs::remove_dir_all(&dir).ok();
1547    }
1548
1549    /// The sidecar is read from the directory beside the weights, the
1550    /// same place HF's `GenerationConfig.from_pretrained` looks.
1551    #[test]
1552    fn a_model_directory_generation_config_is_read_from_beside_the_weights() {
1553        let dir = std::env::temp_dir().join(format!(
1554            "ferrox_test_generation_config_dir_{}",
1555            std::process::id()
1556        ));
1557        std::fs::create_dir_all(&dir).unwrap();
1558        std::fs::write(
1559            dir.join("generation_config.json"),
1560            r#"{"temperature": 0.6, "top_p": 0.95}"#,
1561        )
1562        .unwrap();
1563        let recommended = RecommendedSampling::from_model_dir(&dir);
1564        std::fs::remove_dir_all(&dir).ok();
1565        assert_eq!(recommended.temperature, Some(0.6));
1566        assert_eq!(recommended.top_p, Some(0.95));
1567        assert_eq!(recommended.top_k, None);
1568    }
1569}