ferrox_models/sampling.rs
1//! Token sampling from a decoder's output logits: llama.cpp's whole
2//! default sampler chain, on top of the greedy argmax ferrox previously
3//! always used unconditionally.
4//!
5//! The chain is `penalties, dry, top_n_sigma, top_k, typical_p, top_p,
6//! min_p, xtc, temperature` (`common/common.h:259-269`), which is
7//! [`crate::sampler_order::SamplerOrder`]'s default and llama.cpp's.
8//! The filters themselves are in [`crate::sampler_chain`], the DRY
9//! penalty in [`crate::dry`], the three history penalties in
10//! [`penalties`], the parameters in [`params`].
11//!
12//! `crate::speculative` verifies draft tokens against
13//! [`sampling_distribution`] -- the exact distribution [`Sampler`]
14//! draws from for a given `SamplingParams` -- so speculation is
15//! lossless with respect to whatever sampling configuration the caller
16//! asked for, rather than only at temperature 0.
17//!
18//! No external `rand` dependency: a small xorshift64* generator (the
19//! same algorithm `Decoder::new_random_small`'s test-only `Lcg` already
20//! uses in `decoder.rs`) is enough for sampling and keeps the
21//! dependency tree the same minimal, pure-Rust shape as the rest of
22//! this crate.
23
24mod params;
25mod penalties;
26mod recommended;
27mod rng;
28
29pub use params::SamplingParams;
30pub use recommended::{RecommendedSampling, RequestedSampling};
31pub use rng::{LogitMask, Sampler};
32
33use crate::penalty_window::PenaltyWindow;
34use crate::sampler_chain::Candidates;
35use crate::sampler_order::ChainStep;
36use penalties::apply_history_penalties;
37
38/// A deterministic, uninteresting-on-purpose logit vector: no ties, a
39/// wide dynamic range, and a few negatives so the penalty's sign
40/// convention is exercised.
41///
42/// Shared with [`rng`]'s tests rather than written out twice, because
43/// two "the same logits" that were not the same logits is the smallest
44/// possible instance of this repo's dominant defect.
45#[cfg(test)]
46pub(crate) fn spread_logits(vocab: usize) -> Vec<f32> {
47 (0..vocab)
48 .map(|i| ((i as f32 * 12.9898).sin() * 43_758.547).fract() * 8.0 - 3.0)
49 .collect()
50}
51
52/// The token greedy decoding picks, given a chain that may or may not be
53/// able to move the argmax.
54///
55/// llama.cpp does NOT special-case `temp <= 0`: it runs the whole chain
56/// and lets `llama_sampler_temp_impl` (`src/llama-sampler.cpp:271-286`)
57/// set every logit but the maximum to `-inf`, so `dist` picks whatever
58/// the filters left. Two of those filters can therefore change greedy
59/// output, and both of them are new here: `xtc` removes the TOP
60/// candidates by construction, and `typ_p` selects outward from the
61/// distribution's entropy and may drop the most likely token.
62///
63/// ferrox keeps its `argmax` fast path, because building and sorting a
64/// 128k-entry candidate list per token to reach an answer that cannot
65/// differ would be a decode-speed regression on the most common
66/// configuration there is. [`SamplingParams::greedy_equals_argmax`] is
67/// the one predicate that decides which path is exact, and it is read
68/// here and by the Metal `lm_head + argmax` fold's guard, so a chain
69/// that can move the argmax also stops the GPU from folding it away.
70fn greedy_choice(
71 scores: Vec<f32>,
72 params: &SamplingParams,
73 history: PenaltyWindow<'_>,
74 xtc_roll: Option<f32>,
75) -> usize {
76 if params.greedy_equals_argmax() {
77 return argmax(&scores);
78 }
79 argmax(&filtered_distribution(scores, params, history, xtc_roll))
80}
81
82/// The **exact** distribution [`Sampler::sample`] draws from for these
83/// logits, params and history: penalties applied over the
84/// `penalty_last_n` window, then llama.cpp's chain in
85/// `params.sampler_order`, renormalised to sum to 1.
86///
87/// That is llama.cpp's chain order -- **temperature last**, not first.
88/// This comment used to say "temperature divided in, top-k and top-p
89/// filtered", which described the pre-2026-09-01 pipeline and omitted
90/// min-p entirely.
91///
92/// This is what makes lossless speculative verification possible. The
93/// speculative-sampling rejection rule compares `p_target(x)` against
94/// the draft's `q(x)`, and "the target's probability" is meaningless
95/// unless it is the probability the *configured sampler* would actually
96/// have used -- a rule that compared against the raw softmax while the
97/// server sampled with `top_p = 0.9` would be lossless with respect to
98/// a model nobody is running.
99///
100/// Greedy (`temperature <= 0.0`) is a distribution too: the point mass
101/// on the token [`greedy_choice`] would pick. Returning it as one rather
102/// than as a special case is why the same verification code is correct
103/// at every temperature.
104///
105/// `xtc_roll` is [`Sampler::xtc_roll`]'s answer, and it is a REQUIRED
106/// argument rather than something this function draws or defaults,
107/// because XTC is stochastic and the caller owns the seeded stream. A
108/// caller that passes `None` while XTC is configured gets a chain with
109/// no XTC in it, which is why every caller in this workspace obtains it
110/// from `Sampler::xtc_roll` and not by writing `None`.
111pub fn sampling_distribution(
112 logits: &[f32],
113 params: &SamplingParams,
114 history: PenaltyWindow<'_>,
115 xtc_roll: Option<f32>,
116) -> Vec<f32> {
117 let mut scores = logits.to_vec();
118 apply_history_penalties(&mut scores, params, history);
119 if params.temperature <= 0.0 {
120 let vocab = scores.len();
121 let chosen = greedy_choice(scores, params, history, xtc_roll);
122 let mut probs = vec![0.0f32; vocab];
123 if let Some(p) = probs.get_mut(chosen) {
124 *p = 1.0;
125 }
126 return probs;
127 }
128 filtered_distribution(scores, params, history, xtc_roll)
129}
130
131/// Shared tail of [`Sampler::sample_with_mask`] and
132/// [`sampling_distribution`]: run the already-penalised `scores` through
133/// llama.cpp's sampler chain and return the resulting full-vocabulary
134/// distribution.
135///
136/// # Order, and why it is a specification
137///
138/// llama.cpp's default chain is `penalties, dry, top_n_sigma, top_k,
139/// typical_p, top_p, min_p, xtc, temperature` (`common/common.h:259-269`,
140/// consumed by `common/sampling.cpp:346-397`). The penalties already ran
141/// in [`apply_history_penalties`]; this function is the rest of it, in
142/// that order, and **temperature is last**.
143///
144/// ferrox used to divide by the temperature FIRST and filter afterwards.
145/// That is not a reordering of independent steps. Top-p selects the
146/// smallest set of candidates whose probabilities sum to `p`, and
147/// temperature changes those probabilities: a high temperature flattens
148/// the distribution so the nucleus grows, a low one sharpens it so the
149/// nucleus shrinks. Min-p compares each candidate's logit against
150/// `max + ln(p)`, and temperature scales exactly the gap being compared.
151/// Filtering before scaling and filtering after scaling therefore keep
152/// DIFFERENT candidate sets for the same flags.
153///
154/// Both callers go through here rather than each running their own
155/// chain, because a difference between the two is exactly the kind of
156/// silent non-losslessness speculative verification is supposed to rule
157/// out.
158///
159/// The filters themselves live in [`crate::sampler_chain`], which models
160/// the shrinking candidate list llama.cpp passes down the chain --
161/// including the renormalisation between steps that a keep-mask cannot
162/// express. See that module's header.
163///
164/// # The order is the caller's
165///
166/// `params.sampler_order` says which steps run and in what sequence,
167/// which is llama.cpp's `--samplers`. It DEFAULTS to the sequence
168/// written out above, so a caller that never sets it gets exactly the
169/// chain this function used to hardcode -- asserted bit-for-bit by
170/// [`tests::the_default_order_is_the_chain_ferrox_already_ran`].
171///
172/// The `match` is exhaustive over [`ChainStep`] with no `..`: a step
173/// added to the order's vocabulary stops this compiling until it has
174/// something to run. And because [`SamplerOrder`] can only be built out
175/// of steps ferrox implements, there is no arm here that means "asked
176/// for, silently not done".
177fn filtered_distribution(
178 scores: Vec<f32>,
179 params: &SamplingParams,
180 history: PenaltyWindow<'_>,
181 xtc_roll: Option<f32>,
182) -> Vec<f32> {
183 let vocab = scores.len();
184 let mut candidates = Candidates::new(&scores);
185 for &step in params.sampler_order.steps() {
186 match step {
187 // Already applied to `scores`, before the candidate list
188 // existed. `SamplerOrder` refuses a `penalties` that is not
189 // first precisely so that this is the same position the
190 // caller asked for; see `SamplerOrderError::PenaltiesNotFirst`.
191 ChainStep::Penalties => {}
192 ChainStep::Dry => candidates.dry(¶ms.dry.penalties(history)),
193 ChainStep::TopNSigma => candidates.top_n_sigma(params.top_n_sigma),
194 ChainStep::TopK => candidates.top_k(params.top_k),
195 ChainStep::TypP => candidates.typical_p(params.typical_p),
196 ChainStep::TopP => candidates.top_p(params.top_p),
197 ChainStep::MinP => candidates.min_p(params.min_p),
198 // `xtc_roll` is `None` exactly when
199 // `SamplingParams::xtc_can_fire` is false, which is the same
200 // predicate `Candidates::xtc` re-checks. See
201 // `Sampler::xtc_roll`.
202 ChainStep::Xtc => {
203 if let Some(chance) = xtc_roll {
204 candidates.xtc(params.xtc_probability, params.xtc_threshold, chance);
205 }
206 }
207 ChainStep::Temperature => candidates.temperature(params.temperature),
208 }
209 }
210 candidates.into_distribution(vocab)
211}
212
213fn argmax(logits: &[f32]) -> usize {
214 logits
215 .iter()
216 .enumerate()
217 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
218 .map(|(i, _)| i)
219 .unwrap_or(0)
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use crate::dry::{DryBreakers, DryParams};
226 use crate::sampler_order::SamplerOrder;
227
228 /// The chain `filtered_distribution` ran BEFORE llama.cpp's four
229 /// missing samplers were added, written out by hand.
230 ///
231 /// Deliberately not built from `SamplerOrder`: a reference that read
232 /// the order it is supposed to be pinning would agree with any
233 /// reordering, which is the shape of test that proves nothing.
234 fn the_chain_ferrox_used_to_hardcode(
235 logits: &[f32],
236 params: &SamplingParams,
237 history: PenaltyWindow<'_>,
238 ) -> Vec<f32> {
239 let mut scores = logits.to_vec();
240 apply_history_penalties(&mut scores, params, history);
241 let vocab = scores.len();
242 let mut candidates = Candidates::new(&scores);
243 candidates.top_k(params.top_k);
244 candidates.top_p(params.top_p);
245 candidates.min_p(params.min_p);
246 candidates.temperature(params.temperature);
247 candidates.into_distribution(vocab)
248 }
249
250 /// **A run that does not ask for an order samples exactly what it
251 /// always did.** Bit-for-bit, against the five-step chain written out
252 /// by hand rather than read back off `SamplerOrder`.
253 ///
254 /// This is now doing double duty. It is still the assertion that
255 /// makes `--samplers` safe to expose -- the order is not a
256 /// reordering of independent steps, so a default that drifted by one
257 /// position would change every generation on every model. And it is
258 /// the assertion that adding `dry`, `top_n_sigma`, `typ_p` and `xtc`
259 /// to the DEFAULT chain changed nothing: at their neutral values
260 /// (`dry_multiplier 0.0`, `top_n_sigma -1.0`, `typical_p 1.0`,
261 /// `xtc_probability 0.0`) all four are no-ops, so the nine-step
262 /// default must produce bit-identical probabilities to the five-step
263 /// chain it replaced.
264 ///
265 /// Swap any two entries of `sampler_order::DEFAULT_STEPS`, or make
266 /// any of the four new filters do something at its neutral value,
267 /// and this goes red.
268 #[test]
269 fn the_default_order_is_the_chain_ferrox_already_ran() {
270 let logits = spread_logits(64);
271 let prompt = [3usize, 9, 17, 9];
272 let generated = [9usize, 40, 3];
273 // Every filter switched on, and all three penalties, so there is
274 // something for a misplaced step to change.
275 // Every adjacent pair of the default chain has to be
276 // DISTINGUISHED by at least one row, or the assertion below
277 // passes for a chain in the wrong order. `top_k 5` with
278 // `top_p 0.9` separates top-k from top-p (top-p over the whole
279 // vocabulary keeps far more than five, so which runs first
280 // decides the answer); `min_p 0.2` separates top-p from min-p;
281 // any temperature away from 1.0 separates min-p from
282 // temperature.
283 for (temperature, top_k, top_p, min_p) in [
284 (0.8f32, 5usize, 0.9f32, 0.05f32),
285 (4.0, 3, 0.85, 0.2),
286 (0.2, 8, 0.95, 0.1),
287 (1.0, 40, 0.5, 0.02),
288 (0.8, 40, 0.95, 0.05),
289 ] {
290 let params = SamplingParams {
291 temperature,
292 top_k,
293 top_p,
294 min_p,
295 repetition_penalty: 1.1,
296 presence_penalty: 0.3,
297 frequency_penalty: 0.4,
298 ..SamplingParams::default()
299 };
300 let window = || PenaltyWindow::new(&prompt, &generated);
301 let expected = the_chain_ferrox_used_to_hardcode(&logits, ¶ms, window());
302 let actual = sampling_distribution(&logits, ¶ms, window(), None);
303 for (i, (a, e)) in actual.iter().zip(expected.iter()).enumerate() {
304 assert_eq!(
305 a.to_bits(),
306 e.to_bits(),
307 "token {i} at temp {temperature}, top_k {top_k}, top_p {top_p}, \
308 min_p {min_p}: the default order sampled {a} where the chain ferrox \
309 already ran gives {e}"
310 );
311 }
312 }
313 }
314
315 /// And the same at the token level: the ids a seeded `Sampler` draws
316 /// under `SamplingParams::default()` are the ids it draws when the
317 /// caller spells out the default chain, so the flag's default value
318 /// and the struct's default are one chain and not two.
319 #[test]
320 fn spelling_out_the_default_chain_draws_the_same_tokens() {
321 let logits = spread_logits(48);
322 let base = SamplingParams {
323 temperature: 0.8,
324 top_k: 40,
325 top_p: 0.95,
326 min_p: 0.05,
327 repetition_penalty: 1.1,
328 ..SamplingParams::default()
329 };
330 let spelled = SamplingParams {
331 sampler_order: "penalties;dry;top_n_sigma;top_k;typ_p;top_p;min_p;xtc;temperature"
332 .parse::<SamplerOrder>()
333 .expect("the default chain must parse"),
334 ..base.clone()
335 };
336 let draw = |params: &SamplingParams| {
337 let mut sampler = Sampler::new(0xFE0);
338 let mut generated: Vec<usize> = Vec::new();
339 for _ in 0..64 {
340 let next = sampler.sample(&logits, params, PenaltyWindow::new(&[7], &generated));
341 generated.push(next);
342 }
343 generated
344 };
345 assert_eq!(draw(&base), draw(&spelled));
346 }
347
348 /// A run that never asks for XTC must not consume a draw for it, or
349 /// every seeded generation in the workspace shifts by one.
350 #[test]
351 fn running_the_temperature_first_keeps_a_different_candidate_set() {
352 let logits = vec![6.0f32, 4.0, 2.0, 0.0, -2.0, -4.0];
353 let params = |order: &str| SamplingParams {
354 temperature: 8.0,
355 top_p: 0.9,
356 top_k: 0,
357 min_p: 0.0,
358 sampler_order: order.parse().expect("chain"),
359 ..SamplingParams::default()
360 };
361 let support = |order: &str| -> Vec<bool> {
362 sampling_distribution(&logits, ¶ms(order), PenaltyWindow::new(&[], &[]), None)
363 .iter()
364 .map(|&p| p > 0.0)
365 .collect()
366 };
367
368 let default = support("penalties;top_k;top_p;min_p;temperature");
369 let temperature_first = support("penalties;temperature;top_k;top_p;min_p");
370 assert_ne!(
371 default, temperature_first,
372 "reordering the chain must change which candidates survive, \
373 or the flag is decorative"
374 );
375 assert!(
376 temperature_first.iter().filter(|&&k| k).count()
377 > default.iter().filter(|&&k| k).count(),
378 "temp 8.0 flattens the distribution, so a later top-p keeps more: \
379 default={default:?} temperature_first={temperature_first:?}"
380 );
381 }
382
383 /// A sampler left OUT of the chain does not run, even though its
384 /// knob is set -- llama.cpp reads an omitted sampler as "do not run
385 /// it", and a chain that ran it anyway would be honouring a request
386 /// nobody made.
387 ///
388 /// Checked for every filter that has a knob, not just min-p: the
389 /// four samplers added for llama.cpp parity each have their own arm
390 /// in `filtered_distribution`, and an arm that ignored the chain
391 /// would be invisible to a test that only exercised one of them.
392 #[test]
393 fn a_sampler_absent_from_the_chain_does_not_filter() {
394 let logits = vec![4.0f32, 3.0, 2.0, 1.0];
395 let survivors = |params: &SamplingParams, roll: Option<f32>| {
396 sampling_distribution(&logits, params, PenaltyWindow::new(&[], &[]), roll)
397 .iter()
398 .filter(|&&p| p > 0.0)
399 .count()
400 };
401 // (the knob, a chain WITHOUT its step, the roll to pass)
402 let cases: Vec<(SamplingParams, &str, Option<f32>)> = vec![
403 (
404 SamplingParams {
405 temperature: 1.0,
406 min_p: 0.2,
407 ..SamplingParams::default()
408 },
409 "penalties;top_k;top_p;temperature",
410 None,
411 ),
412 (
413 SamplingParams {
414 temperature: 1.0,
415 typical_p: 0.5,
416 ..SamplingParams::default()
417 },
418 "penalties;top_k;top_p;min_p;temperature",
419 None,
420 ),
421 (
422 SamplingParams {
423 temperature: 1.0,
424 top_n_sigma: 0.5,
425 ..SamplingParams::default()
426 },
427 "penalties;top_k;top_p;min_p;temperature",
428 None,
429 ),
430 (
431 SamplingParams {
432 temperature: 1.0,
433 xtc_probability: 1.0,
434 xtc_threshold: 0.05,
435 ..SamplingParams::default()
436 },
437 "penalties;top_k;top_p;min_p;temperature",
438 Some(0.0),
439 ),
440 ];
441 for (with, chain_without, roll) in cases {
442 let filtered = survivors(&with, roll);
443 assert!(
444 filtered < 4,
445 "the knob must bite when its step IS in the chain, \
446 or the second half proves nothing: {with:?}"
447 );
448 let without = SamplingParams {
449 sampler_order: chain_without.parse().expect("chain"),
450 ..with.clone()
451 };
452 assert_eq!(
453 survivors(&without, roll),
454 4,
455 "the knob is set but its step is not in `{chain_without}`, \
456 so nothing should truncate: {without:?}"
457 );
458 }
459 }
460
461 /// Leaving `penalties` out of the chain disables the penalties, on
462 /// the SAMPLED path and on the greedy one.
463 ///
464 /// The greedy half is the one that would have been missed: the
465 /// penalties are applied before the candidate list exists, so a
466 /// check placed beside the chain would never run at `temp <= 0`,
467 /// and `--samplers` without `penalties` would still have penalised.
468 #[test]
469 fn a_chain_without_penalties_does_not_penalise_on_either_path() {
470 // Token 0 leads token 1 by less than the 1.1 penalty.
471 let logits = vec![4.0f32, 3.9];
472 let history = || PenaltyWindow::new(&[0], &[]);
473 let greedy = SamplingParams {
474 temperature: 0.0,
475 repetition_penalty: 1.1,
476 ..SamplingParams::default()
477 };
478 let mut sampler = Sampler::new(1);
479 assert_eq!(
480 sampler.sample(&logits, &greedy, history()),
481 1,
482 "the default chain penalises the prompt token"
483 );
484
485 let unpenalised = SamplingParams {
486 sampler_order: "top_k;top_p;min_p;temperature".parse().expect("chain"),
487 ..greedy.clone()
488 };
489 assert!(!unpenalised.sampler_order.has_penalties());
490 assert_eq!(
491 sampler.sample(&logits, &unpenalised, history()),
492 0,
493 "`penalties` is not in the chain, so the argmax must stand"
494 );
495
496 // And on the sampled path, where the whole distribution is
497 // visible rather than one argmax.
498 let sampled = SamplingParams {
499 temperature: 1.0,
500 ..unpenalised
501 };
502 let with = SamplingParams {
503 sampler_order: SamplerOrder::default(),
504 ..sampled.clone()
505 };
506 assert_ne!(
507 sampling_distribution(&logits, &sampled, history(), None),
508 sampling_distribution(&logits, &with, history(), None)
509 );
510 }
511
512 /// A token that has only ever appeared in the PROMPT is penalised
513 /// on the very first generated position, and that changes which
514 /// token is sampled.
515 ///
516 /// This is the divergence issue #55 reported. llama.cpp seeds its
517 /// penalties sampler with every prompt token before drawing
518 /// anything (`tools/server/server-context.cpp:386-390`,
519 /// `tools/completion/completion.cpp:730-736`); ferrox's decode
520 /// loops handed the sampler the generated tokens alone, so the same
521 /// checkpoint, flags and prompt could produce different text at the
522 /// default `--repeat-penalty 1.1`.
523 ///
524 /// Asserted on the SAMPLED TOKEN rather than on the window's
525 /// contents: a test that only checked the slice could not tell the
526 /// window being applied to the wrong distribution from the window
527 /// being wrong. Drop `prompt` from `PenaltyWindow::recent` and this
528 /// goes red -- the second assertion returns 0.
529 #[test]
530 fn a_prompt_token_is_penalised_before_it_is_ever_generated() {
531 let params = SamplingParams {
532 // Greedy, so the assertion is on the chosen id and not on a
533 // draw. Everything below is arithmetic, not sampling.
534 temperature: 0.0,
535 repetition_penalty: 1.1,
536 ..SamplingParams::default()
537 };
538 // Token 0 leads token 1 by less than the 1.1 penalty: 4.0 / 1.1
539 // = 3.636, which is below 3.9.
540 let logits = vec![4.0f32, 3.9];
541 let mut sampler = Sampler::new(1);
542
543 assert_eq!(
544 sampler.sample(&logits, ¶ms, PenaltyWindow::new(&[], &[])),
545 0,
546 "with nothing behind it the argmax wins"
547 );
548 assert_eq!(
549 sampler.sample(&logits, ¶ms, PenaltyWindow::new(&[0], &[])),
550 1,
551 "token 0 is in the prompt, so llama.cpp penalises it here"
552 );
553 // And a window that reaches back past the prompt is the same
554 // answer, which is what makes the two halves one sequence.
555 assert_eq!(
556 sampler.sample(&logits, ¶ms, PenaltyWindow::new(&[9, 0], &[8])),
557 1
558 );
559 }
560
561 /// `penalty_last_n` counts across the prompt/generated seam, so a
562 /// prompt token falls OUT of the window once enough tokens have
563 /// been generated after it -- and the sampled token moves back.
564 ///
565 /// A window that added the whole prompt to the last N generated
566 /// tokens would keep penalising token 0 forever and this would stay
567 /// at 1.
568 #[test]
569 fn a_prompt_token_leaves_the_window_once_the_generation_outgrows_it() {
570 let params = SamplingParams {
571 temperature: 0.0,
572 repetition_penalty: 1.1,
573 penalty_last_n: 2,
574 ..SamplingParams::default()
575 };
576 let logits = vec![4.0f32, 3.9];
577 let mut sampler = Sampler::new(1);
578
579 // Prompt token 0, one token generated: the window is [0, 5] and
580 // token 0 is still penalised.
581 assert_eq!(
582 sampler.sample(&logits, ¶ms, PenaltyWindow::new(&[0], &[5])),
583 1
584 );
585 // Two generated: the window is [5, 6] and token 0 is clear.
586 assert_eq!(
587 sampler.sample(&logits, ¶ms, PenaltyWindow::new(&[0], &[5, 6])),
588 0
589 );
590 }
591
592 /// Top-p cuts the UNSCALED distribution; the temperature reshapes
593 /// only the survivors.
594 ///
595 /// llama.cpp's default chain runs temperature LAST
596 /// (`common/common.h:259-269`); ferrox divided first and filtered
597 /// afterwards. Not an innocuous reordering: temperature changes the
598 /// probabilities top-p sums over, so a high temperature flattens the
599 /// distribution and grows the nucleus. The two orders keep different
600 /// candidate sets for identical flags.
601 #[test]
602 fn temperature_does_not_change_which_candidates_top_p_keeps() {
603 let logits = vec![3.0f32, 2.0, 1.0, 0.0];
604 let at = |temperature: f32| -> Vec<bool> {
605 let params = SamplingParams {
606 temperature,
607 top_p: 0.9,
608 top_k: 0,
609 ..SamplingParams::default()
610 };
611 sampling_distribution(&logits, ¶ms, PenaltyWindow::new(&[], &[]), None)
612 .iter()
613 .map(|&p| p > 0.0)
614 .collect()
615 };
616
617 let cold = at(0.5);
618 let hot = at(4.0);
619 assert_eq!(
620 cold, hot,
621 "the surviving set must not depend on the temperature: \
622 cold={cold:?} hot={hot:?}"
623 );
624 // And the cut must actually bite, or the equality above is
625 // satisfied by keeping everything.
626 assert!(
627 cold.iter().any(|&k| !k),
628 "top_p = 0.9 must drop at least one of these four candidates"
629 );
630 }
631
632 /// min-p truncates, and it truncates on llama.cpp's threshold.
633 ///
634 /// llama.cpp enables min-p **by default** at 0.05
635 /// (`common/common.h:231`), so until this existed ferrox could not
636 /// reproduce llama.cpp's own out-of-the-box output on any prompt --
637 /// a parity gap, not a missing feature.
638 ///
639 /// Logits `[4, 3, 2, 1]` at `min_p = 0.2`: the threshold is
640 /// `4 + ln(0.2) = 2.3905`, so exactly the candidates at 4 and 3
641 /// survive. Arithmetic done by hand from
642 /// `src/llama-sampler.cpp:1556`, not read back off the code.
643 #[test]
644 fn min_p_truncates_at_ln_p_below_the_top_logit() {
645 let logits = vec![4.0f32, 3.0, 2.0, 1.0];
646 let params = SamplingParams {
647 temperature: 1.0,
648 min_p: 0.2,
649 ..SamplingParams::default()
650 };
651 let probs = sampling_distribution(&logits, ¶ms, PenaltyWindow::new(&[], &[]), None);
652 assert!(probs[0] > 0.0 && probs[1] > 0.0);
653 assert_eq!(probs[2], 0.0, "2.0 is below 4 + ln(0.2) = 2.3905");
654 assert_eq!(probs[3], 0.0);
655 assert!((probs.iter().sum::<f32>() - 1.0).abs() < 1e-6);
656
657 // The two survivors are renormalised against each other:
658 // e^4 / (e^4 + e^3) = 0.7311.
659 assert!((probs[0] - 0.731_059).abs() < 1e-5, "got {}", probs[0]);
660
661 // 0.0 disables it, which is ferrox's struct default -- adding
662 // min-p must not change any existing caller's distribution.
663 let off = SamplingParams {
664 min_p: 0.0,
665 ..params.clone()
666 };
667 let unfiltered = sampling_distribution(&logits, &off, PenaltyWindow::new(&[], &[]), None);
668 assert!(unfiltered.iter().all(|&p| p > 0.0));
669 }
670
671 /// min-p runs BEFORE the temperature, so the set it keeps does not
672 /// depend on `--temp`.
673 ///
674 /// This is the same trap as E4 and it bites harder here. min-p's
675 /// test is `logit_i >= logit_max + ln(p)`, and temperature divides
676 /// **both** logits, so it scales the very gap being compared against
677 /// a fixed `ln(p)`. On these logits at `min_p = 0.2`, running min-p
678 /// after a temperature of 0.5 would keep one candidate and after 2.0
679 /// would keep all four; llama.cpp keeps two at every temperature
680 /// (`common/common.h:259-269` puts `MIN_P` before `TEMPERATURE`).
681 ///
682 /// Move `candidates.min_p(..)` after `candidates.temperature(..)` in
683 /// `filtered_distribution` and this goes red.
684 #[test]
685 fn temperature_does_not_change_which_candidates_min_p_keeps() {
686 let logits = vec![3.0f32, 2.0, 1.0, 0.0];
687 let survivors = |temperature: f32| -> Vec<bool> {
688 let params = SamplingParams {
689 temperature,
690 min_p: 0.2,
691 ..SamplingParams::default()
692 };
693 sampling_distribution(&logits, ¶ms, PenaltyWindow::new(&[], &[]), None)
694 .iter()
695 .map(|&p| p > 0.0)
696 .collect()
697 };
698
699 let cold = survivors(0.5);
700 let warm = survivors(1.0);
701 let hot = survivors(2.0);
702 assert_eq!(cold, warm, "cold={cold:?} warm={warm:?}");
703 assert_eq!(warm, hot, "warm={warm:?} hot={hot:?}");
704 // 3 + ln(0.2) = 1.3905, so exactly the 3.0 and 2.0 candidates.
705 assert_eq!(warm, vec![true, true, false, false]);
706 }
707
708 /// min-p sits AFTER top-p in the chain, and both may bite on the
709 /// same call.
710 ///
711 /// `top_p = 0.95` on this distribution keeps three candidates
712 /// (0.6337 + 0.2331 + 0.0857 = 0.9525); min-p at 0.2 then drops the
713 /// third, whose probability is 0.135 of the top one. Getting only
714 /// one of the two filters gives a different answer either way, so
715 /// this fails if either is dropped or if min-p is skipped when top-p
716 /// already truncated.
717 #[test]
718 fn top_p_and_min_p_both_apply() {
719 let logits = vec![3.0f32, 2.0, 1.0, 0.0];
720 let params = SamplingParams {
721 temperature: 1.0,
722 top_p: 0.95,
723 min_p: 0.2,
724 ..SamplingParams::default()
725 };
726 let probs = sampling_distribution(&logits, ¶ms, PenaltyWindow::new(&[], &[]), None);
727 assert_eq!(
728 probs.iter().map(|&p| p > 0.0).collect::<Vec<_>>(),
729 vec![true, true, false, false]
730 );
731
732 // top-p alone keeps three; min-p alone also keeps two here, so
733 // pin the top-p-only case to prove the two filters are distinct
734 // and that this test is not satisfied by min-p doing all the
735 // work.
736 let top_p_only = SamplingParams {
737 min_p: 0.0,
738 ..params.clone()
739 };
740 assert_eq!(
741 sampling_distribution(&logits, &top_p_only, PenaltyWindow::new(&[], &[]), None)
742 .iter()
743 .filter(|&&p| p > 0.0)
744 .count(),
745 3
746 );
747 }
748
749 /// DRY reaches the sampled token through the chain, not just the
750 /// penalty table.
751 ///
752 /// Window `0 1 2 0 1` at `allowed_length 2`: emitting `2` would make
753 /// it a three-token repetition, so DRY subtracts
754 /// `multiplier * base^0 = 6.0` from token 2's logit of 5.0 -- more
755 /// than enough to move the greedy argmax off it. That is the whole
756 /// claim: a sampler wired into `filtered_distribution` but not
757 /// reached from the greedy path would leave this at 2.
758 #[test]
759 fn dry_moves_the_chosen_token_on_both_the_greedy_and_the_sampled_path() {
760 let logits = vec![0.0f32, 0.0, 5.0, 0.0];
761 let history = || PenaltyWindow::new(&[], &[0, 1, 2, 0, 1]);
762 let dry = DryParams::new(6.0, 1.1, 2, -1, 1024, DryBreakers::none());
763 let greedy = SamplingParams {
764 temperature: 0.0,
765 dry: dry.clone(),
766 ..SamplingParams::default()
767 };
768 let mut sampler = Sampler::new(5);
769 assert_eq!(
770 sampler.sample(&logits, &SamplingParams::default(), history()),
771 2,
772 "without DRY token 2 is the argmax"
773 );
774 assert_ne!(
775 sampler.sample(&logits, &greedy, history()),
776 2,
777 "DRY subtracts 4.0 from token 2's logit of 5.0, so it loses"
778 );
779
780 // Same on the sampled path, read off the distribution.
781 let sampled = SamplingParams {
782 temperature: 1.0,
783 ..greedy.clone()
784 };
785 let with = sampling_distribution(&logits, &sampled, history(), None);
786 let without = sampling_distribution(
787 &logits,
788 &SamplingParams {
789 dry: DryParams::off(),
790 ..sampled
791 },
792 history(),
793 None,
794 );
795 assert!(with[2] < without[2], "with={with:?} without={without:?}");
796 }
797
798 /// XTC removes the TOP candidates, so it can change greedy output --
799 /// and ferrox's greedy fast path knows that.
800 ///
801 /// `greedy_equals_argmax` is the predicate that decides whether the
802 /// `argmax` shortcut is exact. Make it return `true`
803 /// unconditionally and this goes red: the shortcut would return
804 /// token 0 while llama.cpp's chain, which runs XTC before the
805 /// temperature at every temperature, returns something else.
806 #[test]
807 fn xtc_changes_the_greedy_choice_because_it_removes_the_top() {
808 let logits = vec![3.0f32, 2.9, -10.0];
809 let params = SamplingParams {
810 temperature: 0.0,
811 // Always fires, and both leading candidates clear the
812 // threshold, so the more likely of the two is removed.
813 xtc_probability: 1.0,
814 xtc_threshold: 0.05,
815 ..SamplingParams::default()
816 };
817 assert!(!params.greedy_equals_argmax());
818 let mut sampler = Sampler::new(11);
819 assert_eq!(
820 sampler.sample(&logits, ¶ms, PenaltyWindow::new(&[], &[])),
821 1,
822 "XTC drops token 0, so the greedy answer is token 1"
823 );
824 // Without XTC the argmax stands, which is what makes the
825 // assertion above about XTC and not about the logits.
826 assert_eq!(
827 sampler.sample(
828 &logits,
829 &SamplingParams::default(),
830 PenaltyWindow::new(&[], &[])
831 ),
832 0
833 );
834 }
835
836 /// The same for typical-p: it selects outward from the entropy and
837 /// can drop the most likely token, so the greedy shortcut is not
838 /// exact when it is live.
839 #[test]
840 fn typical_p_can_drop_the_argmax_so_greedy_must_run_the_chain() {
841 // One near-certain token and three equal small ones. The
842 // entropy is dominated by the small tokens, so the near-certain
843 // one is the ATYPICAL member and typical-p at 0.5 keeps it
844 // alone here -- while at 0.5 on a flatter distribution it drops
845 // the leader. The property under test is the predicate.
846 let params = SamplingParams {
847 temperature: 0.0,
848 typical_p: 0.5,
849 ..SamplingParams::default()
850 };
851 assert!(!params.greedy_equals_argmax());
852 assert!(SamplingParams {
853 typical_p: 1.0,
854 ..params.clone()
855 }
856 .greedy_equals_argmax());
857
858 // logits ln(0.4), ln(0.2) x3: llama.cpp keeps the three 0.2
859 // candidates and drops the 0.4 leader (`tests/test-sampling.cpp:346`),
860 // so the greedy answer must not be token 0.
861 let logits: Vec<f32> = [0.4f32, 0.2, 0.2, 0.2].iter().map(|p| p.ln()).collect();
862 let mut sampler = Sampler::new(3);
863 assert_ne!(
864 sampler.sample(&logits, ¶ms, PenaltyWindow::new(&[], &[])),
865 0,
866 "typical-p drops the most likely token here"
867 );
868 }
869
870 #[test]
871 fn greedy_is_published_as_a_point_mass_not_a_special_case() {
872 let logits = vec![0.1, 0.9, 0.3, -0.2];
873 let probs = sampling_distribution(
874 &logits,
875 &SamplingParams::default(),
876 PenaltyWindow::new(&[], &[]),
877 None,
878 );
879 assert_eq!(probs, vec![0.0, 1.0, 0.0, 0.0]);
880 // Penalties still apply at temperature 0, so the point mass
881 // moves with them.
882 let penalized = sampling_distribution(
883 &logits,
884 &SamplingParams {
885 repetition_penalty: 100.0,
886 ..SamplingParams::default()
887 },
888 PenaltyWindow::new(&[], &[1]),
889 None,
890 );
891 assert_eq!(penalized[1], 0.0);
892 assert_eq!(penalized.iter().sum::<f32>(), 1.0);
893 }
894}