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