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