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