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