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