Skip to main content

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