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
17/// Sampling parameters for one generation request. `temperature <= 0.0`
18/// means "sample nothing, take the greedy argmax" -- the same
19/// deterministic behavior ferrox always had before this module existed.
20#[derive(Debug, Clone)]
21pub struct SamplingParams {
22 pub temperature: f32,
23 /// Nucleus sampling threshold in (0.0, 1.0]. 1.0 disables top-p
24 /// filtering (every token with nonzero probability is eligible).
25 pub top_p: f32,
26 /// Keep only the `top_k` highest-probability tokens before
27 /// sampling. 0 disables top-k filtering.
28 pub top_k: usize,
29 /// > 1.0 discourages repeating a token already in `history`; 1.0
30 /// > disables repetition penalty. Uses the standard convention
31 /// > (divide positive logits, multiply negative ones) so the penalty
32 /// > always pushes toward *less* likely, regardless of logit sign.
33 pub repetition_penalty: f32,
34 /// OpenAI-style presence penalty: subtract from logits of tokens
35 /// that already appeared in `history` (once per distinct token).
36 pub presence_penalty: f32,
37 /// OpenAI-style frequency penalty: subtract `frequency_penalty *
38 /// count` from logits for each token id seen in `history`.
39 pub frequency_penalty: f32,
40}
41
42impl Default for SamplingParams {
43 /// Greedy decoding: identical behavior to ferrox's original
44 /// argmax-only generation loop.
45 fn default() -> Self {
46 SamplingParams {
47 temperature: 0.0,
48 top_p: 1.0,
49 top_k: 0,
50 repetition_penalty: 1.0,
51 presence_penalty: 0.0,
52 frequency_penalty: 0.0,
53 }
54 }
55}
56
57/// The sampling a **checkpoint recommends for itself**, one `Option`
58/// per field so that "this model says nothing about top_p" stays
59/// distinguishable from "this model recommends top_p = 1.0". This is
60/// sglang's `sampling_defaults='model'`, ported from FreeToken
61/// `python/freetoken/utils/hf.py:92 load_generation_sampling`.
62///
63/// Every field is `None` for a checkpoint that recommends nothing,
64/// which is the overwhelming majority, and
65/// [`RecommendedSampling::resolve`] then reproduces ferrox's existing
66/// defaults exactly -- a recommendation may only fill a gap the request
67/// left, never override it.
68///
69/// Why this exists at all: reasoning checkpoints are tuned for a
70/// specific sampler (Qwen3.5 ships temperature 1.0, top_k 20, top_p
71/// 0.95) and ship those numbers with the weights. Served under a
72/// generic greedy-or-0.8 default they fall into repetition loops --
73/// fluent output that never terminates -- which reads as a broken model
74/// rather than as a serving default nobody read off the file.
75#[derive(Debug, Clone, Copy, Default, PartialEq)]
76pub struct RecommendedSampling {
77 pub temperature: Option<f32>,
78 pub top_p: Option<f32>,
79 pub top_k: Option<usize>,
80}
81
82/// The sampling fields **one request** actually specified. `None` means
83/// the request said nothing about that field, so the checkpoint's
84/// recommendation (and then the framework default) may speak for it.
85///
86/// Collapsing this to a plain [`SamplingParams`] at the wire boundary
87/// -- `temperature: req.temperature.unwrap_or(0.0)` -- is what destroys
88/// the distinction: a request that omitted `temperature` becomes
89/// indistinguishable from one that explicitly asked for greedy, and no
90/// recommendation can ever apply.
91#[derive(Debug, Clone, Copy, Default, PartialEq)]
92pub struct RequestedSampling {
93 pub temperature: Option<f32>,
94 pub top_p: Option<f32>,
95 pub top_k: Option<usize>,
96}
97
98impl RecommendedSampling {
99 /// True when the checkpoint recommended nothing at all, i.e.
100 /// [`Self::resolve`] is guaranteed to return the framework defaults
101 /// for any request. Useful for telling an operator whether
102 /// "model defaults" had anything to act on.
103 pub fn is_empty(&self) -> bool {
104 *self == RecommendedSampling::default()
105 }
106
107 /// Precedence, exactly as FreeToken's `resolve_sampling.pick`
108 /// (`python/freetoken/server/generation.py:170`) applies it: the
109 /// **request's** own value, else the **checkpoint's**
110 /// recommendation, else the **framework** default carried by
111 /// `framework` (ferrox's `SamplingParams::default()` unless a caller
112 /// has its own).
113 ///
114 /// The penalty fields are taken from `framework` untouched: nothing
115 /// in the reference reads a recommended penalty, and inventing one
116 /// here would be this function changing generation on its own.
117 ///
118 /// Getting the order wrong in either direction is a silent
119 /// behaviour change: recommendation-over-request makes a client's
120 /// explicit `temperature: 0` unreachable on a model that recommends
121 /// 1.0, and framework-over-recommendation is the greedy repetition
122 /// loop this whole path exists to avoid.
123 pub fn resolve(
124 &self,
125 requested: RequestedSampling,
126 framework: SamplingParams,
127 ) -> SamplingParams {
128 SamplingParams {
129 temperature: requested
130 .temperature
131 .or(self.temperature)
132 .unwrap_or(framework.temperature),
133 top_p: requested.top_p.or(self.top_p).unwrap_or(framework.top_p),
134 top_k: requested.top_k.or(self.top_k).unwrap_or(framework.top_k),
135 ..framework
136 }
137 }
138
139 /// The recommendation in a HuggingFace-style `generation_config.json`
140 /// body.
141 ///
142 /// Two rules, both from the reference
143 /// (`hf.py:92 load_generation_sampling`):
144 ///
145 /// * `do_sample: false` means the checkpoint recommends **greedy**,
146 /// which is returned as `temperature = 0` and *nothing else* --
147 /// the top_k/top_p in such a file describe a sampler the model
148 /// asks not to be used.
149 /// * otherwise only the keys **actually present** are returned. An
150 /// absent key stays `None`; filling it with a house default (the
151 /// naive reading, and what HF's own `GenerationConfig` object does
152 /// for you) would turn silence into a recommendation and let a
153 /// file that says only `temperature: 0.6` also pin top_p to 1.0,
154 /// overriding the server's own default for a value the checkpoint
155 /// never expressed.
156 ///
157 /// A file that does not parse, or is not a JSON object, recommends
158 /// nothing -- a malformed sidecar must not be able to change how a
159 /// model is sampled.
160 pub fn from_generation_config(json: &str) -> Self {
161 let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(json)
162 else {
163 return RecommendedSampling::default();
164 };
165 if map.get("do_sample").and_then(|v| v.as_bool()) == Some(false) {
166 return RecommendedSampling {
167 temperature: Some(0.0),
168 ..RecommendedSampling::default()
169 };
170 }
171 RecommendedSampling {
172 temperature: map
173 .get("temperature")
174 .and_then(|v| v.as_f64())
175 .map(|v| v as f32),
176 top_p: map.get("top_p").and_then(|v| v.as_f64()).map(|v| v as f32),
177 top_k: map
178 .get("top_k")
179 .and_then(|v| v.as_u64())
180 .map(|v| v as usize),
181 }
182 }
183
184 /// [`Self::from_generation_config`] for the `generation_config.json`
185 /// beside a checkpoint's weights (an HF-format model directory).
186 ///
187 /// A directory with no such file recommends nothing, exactly like a
188 /// GGUF with no `general.sampling.*` keys: the absence of a
189 /// recommendation is the normal case and must never be an error that
190 /// stops a model from loading.
191 pub fn from_model_dir(dir: &std::path::Path) -> Self {
192 match std::fs::read_to_string(dir.join("generation_config.json")) {
193 Ok(text) => Self::from_generation_config(&text),
194 Err(_) => RecommendedSampling::default(),
195 }
196 }
197}
198
199/// Zeroes out logits a caller wants to forbid, in place, before the
200/// sampler looks at them. Used by JSON-object mode to keep generation
201/// inside the grammar.
202pub type LogitMask<'a> = &'a mut dyn FnMut(&mut [f32]);
203
204/// A small, seedable xorshift64* generator. Not cryptographically
205/// secure -- sampling doesn't need that -- but reproducible given a
206/// seed, which greedy argmax already was for free.
207pub struct Sampler {
208 state: u64,
209}
210
211impl Sampler {
212 pub fn new(seed: u64) -> Self {
213 // xorshift64* requires a nonzero seed.
214 Sampler {
215 state: if seed == 0 { 0x9E3779B97F4A7C15 } else { seed },
216 }
217 }
218
219 fn next_u64(&mut self) -> u64 {
220 self.state ^= self.state << 13;
221 self.state ^= self.state >> 7;
222 self.state ^= self.state << 17;
223 // The `*` in xorshift64*. Without it this is plain xorshift64,
224 // whose state IS its output, and a small seed's first output is
225 // therefore still small: for every seed below ~4000 the first
226 // draw landed in the bottom eighth of [0, 1), so a request that
227 // asked for `seed: 42` always got its first token from the
228 // bottom of the CDF. The multiply is what decorrelates the
229 // output from a low-entropy state; see
230 // `low_seeds_do_not_bias_the_first_draw`.
231 self.state.wrapping_mul(0x2545F491_4F6CDD1D)
232 }
233
234 /// Uniform float in [0.0, 1.0).
235 fn next_f32(&mut self) -> f32 {
236 (self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
237 }
238
239 /// Samples one token id from `logits`, given `params` and the
240 /// already-generated `history` (for repetition penalty). Falls back
241 /// to plain greedy argmax when `params.temperature <= 0.0`.
242 ///
243 /// A length-1 `logits` vector is treated as a precomputed greedy token
244 /// id (`logits[0] as usize`) — used by the Metal dense-stack path that
245 /// returns GPU argmax instead of downloading the full vocab.
246 pub fn sample(&mut self, logits: &[f32], params: &SamplingParams, history: &[usize]) -> usize {
247 self.sample_with_mask(logits, params, history, None)
248 }
249
250 /// Like [`Self::sample`], but optionally zeroes disallowed logits via
251 /// `mask` before argmax / nucleus sampling (used for JSON-object mode).
252 pub fn sample_with_mask(
253 &mut self,
254 logits: &[f32],
255 params: &SamplingParams,
256 history: &[usize],
257 mut mask: Option<LogitMask<'_>>,
258 ) -> usize {
259 if params.temperature <= 0.0 && mask.is_none() {
260 if logits.len() == 1 {
261 return logits[0] as usize;
262 }
263 let mut scores = logits.to_vec();
264 apply_history_penalties(&mut scores, params, history);
265 return argmax(&scores);
266 }
267
268 let mut scores: Vec<f32> = logits.to_vec();
269 apply_history_penalties(&mut scores, params, history);
270
271 if let Some(m) = mask.as_mut() {
272 m(&mut scores);
273 }
274
275 if params.temperature <= 0.0 {
276 if scores.len() == 1 {
277 return scores[0] as usize;
278 }
279 return argmax(&scores);
280 }
281
282 let probs = filtered_distribution(scores, logits, params);
283 self.sample_from(&probs)
284 }
285
286 /// A uniform draw in `[0.0, 1.0)`.
287 ///
288 /// Exposed because speculative decoding's accept test is a coin
289 /// flip against `p_target(x) / p_draft(x)` rather than a draw from
290 /// a distribution, and it must come off the same seeded stream as
291 /// every other draw in the run or a "reproducible given a seed"
292 /// generation stops being reproducible.
293 pub fn uniform(&mut self) -> f32 {
294 self.next_f32()
295 }
296
297 /// Draws one index from an already-normalised distribution.
298 ///
299 /// Split out of [`Self::sample_with_mask`] so speculative decoding
300 /// can sample from a distribution it had to compute anyway (the
301 /// rejection rule needs `p_target` itself, not just a draw from it)
302 /// and still go through *exactly* the same draw as ordinary
303 /// sampling. Two separate copies of this loop would be two chances
304 /// to be subtly non-lossless.
305 pub fn sample_from(&mut self, probs: &[f32]) -> usize {
306 let draw = self.next_f32();
307 let mut cumulative = 0.0f32;
308 for (i, &p) in probs.iter().enumerate() {
309 cumulative += p;
310 if draw < cumulative {
311 return i;
312 }
313 }
314 // Floating-point rounding may leave `draw` fractionally above
315 // the final cumulative sum; the last nonzero-probability token
316 // is the correct fallback, not index 0.
317 probs
318 .iter()
319 .enumerate()
320 .rev()
321 .find(|&(_, &p)| p > 0.0)
322 .map(|(i, _)| i)
323 .unwrap_or(0)
324 }
325}
326
327/// The **exact** distribution [`Sampler::sample`] draws from for these
328/// logits, params and history: penalties applied, temperature divided
329/// in, top-k and top-p filtered, renormalised to sum to 1.
330///
331/// This is what makes lossless speculative verification possible. The
332/// speculative-sampling rejection rule compares `p_target(x)` against
333/// the draft's `q(x)`, and "the target's probability" is meaningless
334/// unless it is the probability the *configured sampler* would actually
335/// have used -- a rule that compared against the raw softmax while the
336/// server sampled with `top_p = 0.9` would be lossless with respect to
337/// a model nobody is running.
338///
339/// Greedy (`temperature <= 0.0`) is a distribution too: the point mass
340/// on the argmax. Returning it as one rather than as a special case is
341/// why the same verification code is correct at every temperature.
342pub fn sampling_distribution(
343 logits: &[f32],
344 params: &SamplingParams,
345 history: &[usize],
346) -> Vec<f32> {
347 let mut scores = logits.to_vec();
348 apply_history_penalties(&mut scores, params, history);
349 if params.temperature <= 0.0 {
350 let mut probs = vec![0.0f32; scores.len()];
351 if let Some(p) = probs.get_mut(argmax(&scores)) {
352 *p = 1.0;
353 }
354 return probs;
355 }
356 filtered_distribution(scores, logits, params)
357}
358
359/// Shared tail of [`Sampler::sample_with_mask`] and
360/// [`sampling_distribution`]: divide the already-penalised `scores` by
361/// the temperature, softmax, apply top-k and top-p, and renormalise.
362/// `raw_logits` is only consulted for the degenerate
363/// everything-filtered-to-zero fallback.
364///
365/// Both callers go through here rather than each doing their own
366/// temperature-then-filter, because a difference between the two is
367/// exactly the kind of silent non-losslessness speculative
368/// verification is supposed to rule out.
369fn filtered_distribution(
370 mut scores: Vec<f32>,
371 raw_logits: &[f32],
372 params: &SamplingParams,
373) -> Vec<f32> {
374 for s in scores.iter_mut() {
375 *s /= params.temperature;
376 }
377 let mut probs = softmax(&scores);
378
379 if params.top_k > 0 && params.top_k < probs.len() {
380 let mut idx: Vec<usize> = (0..probs.len()).collect();
381 idx.sort_unstable_by(|&a, &b| probs[b].partial_cmp(&probs[a]).unwrap());
382 for &i in idx.iter().skip(params.top_k) {
383 probs[i] = 0.0;
384 }
385 }
386
387 if params.top_p < 1.0 {
388 let mut idx: Vec<usize> = (0..probs.len()).collect();
389 idx.sort_unstable_by(|&a, &b| probs[b].partial_cmp(&probs[a]).unwrap());
390 let mut cumulative = 0.0f32;
391 let mut cutoff = idx.len();
392 for (rank, &i) in idx.iter().enumerate() {
393 cumulative += probs[i];
394 if cumulative >= params.top_p {
395 cutoff = rank + 1;
396 break;
397 }
398 }
399 for &i in idx.iter().skip(cutoff) {
400 probs[i] = 0.0;
401 }
402 }
403
404 let total: f32 = probs.iter().sum();
405 if total <= 0.0 {
406 // Every candidate got filtered to zero (degenerate params);
407 // fall back to greedy rather than sampling from nothing.
408 let mut point = vec![0.0f32; probs.len()];
409 if let Some(p) = point.get_mut(argmax(raw_logits)) {
410 *p = 1.0;
411 }
412 return point;
413 }
414 for p in probs.iter_mut() {
415 *p /= total;
416 }
417 probs
418}
419
420fn apply_history_penalties(scores: &mut [f32], params: &SamplingParams, history: &[usize]) {
421 if params.repetition_penalty != 1.0 {
422 for &tok in history {
423 if let Some(s) = scores.get_mut(tok) {
424 *s = if *s > 0.0 {
425 *s / params.repetition_penalty
426 } else {
427 *s * params.repetition_penalty
428 };
429 }
430 }
431 }
432 if params.presence_penalty != 0.0 || params.frequency_penalty != 0.0 {
433 let mut counts = std::collections::HashMap::<usize, usize>::new();
434 for &tok in history {
435 *counts.entry(tok).or_insert(0) += 1;
436 }
437 for (tok, count) in counts {
438 if let Some(s) = scores.get_mut(tok) {
439 *s -= params.frequency_penalty * count as f32;
440 *s -= params.presence_penalty;
441 }
442 }
443 }
444}
445
446fn argmax(logits: &[f32]) -> usize {
447 logits
448 .iter()
449 .enumerate()
450 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
451 .map(|(i, _)| i)
452 .unwrap_or(0)
453}
454
455fn softmax(logits: &[f32]) -> Vec<f32> {
456 let max = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
457 let exps: Vec<f32> = logits.iter().map(|&l| (l - max).exp()).collect();
458 let sum: f32 = exps.iter().sum();
459 if sum <= 0.0 {
460 vec![1.0 / logits.len().max(1) as f32; logits.len()]
461 } else {
462 exps.into_iter().map(|e| e / sum).collect()
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 #[test]
471 fn temperature_zero_accepts_precomputed_argmax_singleton() {
472 let mut sampler = Sampler::new(1);
473 let params = SamplingParams::default();
474 assert_eq!(sampler.sample(&[42.0], ¶ms, &[]), 42);
475 // Non-greedy must not treat a singleton as a token id.
476 let sampled = SamplingParams {
477 temperature: 0.8,
478 ..SamplingParams::default()
479 };
480 // Softmax of a single logit → only token 0 is eligible.
481 assert_eq!(sampler.sample(&[42.0], &sampled, &[]), 0);
482 }
483
484 #[test]
485 fn temperature_zero_is_deterministic_greedy_argmax() {
486 let logits = vec![0.1, 0.9, 0.3, -0.2];
487 let params = SamplingParams::default();
488 let mut sampler = Sampler::new(42);
489 assert_eq!(sampler.sample(&logits, ¶ms, &[]), 1);
490 // Must be deterministic regardless of RNG state advancing.
491 assert_eq!(sampler.sample(&logits, ¶ms, &[]), 1);
492 }
493
494 #[test]
495 fn high_temperature_can_pick_a_non_argmax_token_over_many_draws() {
496 let logits = vec![1.0, 1.0, 1.0, 1.0];
497 let params = SamplingParams {
498 temperature: 1.0,
499 ..SamplingParams::default()
500 };
501 let mut sampler = Sampler::new(7);
502 let mut seen = std::collections::HashSet::new();
503 for _ in 0..200 {
504 seen.insert(sampler.sample(&logits, ¶ms, &[]));
505 }
506 assert!(
507 seen.len() > 1,
508 "uniform logits at temperature=1.0 must produce more than one distinct token across 200 draws"
509 );
510 }
511
512 #[test]
513 fn top_k_one_is_equivalent_to_greedy() {
514 let logits = vec![0.1, 0.9, 0.3, -0.2];
515 let params = SamplingParams {
516 temperature: 1.0,
517 top_k: 1,
518 ..SamplingParams::default()
519 };
520 let mut sampler = Sampler::new(123);
521 for _ in 0..20 {
522 assert_eq!(sampler.sample(&logits, ¶ms, &[]), 1);
523 }
524 }
525
526 #[test]
527 fn top_p_near_zero_is_equivalent_to_greedy() {
528 let logits = vec![0.1, 5.0, 0.3, -0.2];
529 let params = SamplingParams {
530 temperature: 1.0,
531 top_p: 0.001,
532 ..SamplingParams::default()
533 };
534 let mut sampler = Sampler::new(9);
535 for _ in 0..20 {
536 assert_eq!(sampler.sample(&logits, ¶ms, &[]), 1);
537 }
538 }
539
540 #[test]
541 fn presence_and_frequency_penalties_reduce_seen_token_logits() {
542 let logits = vec![0.0, 5.0, 0.0];
543 let params = SamplingParams {
544 temperature: 1.0,
545 presence_penalty: 10.0,
546 frequency_penalty: 0.0,
547 ..SamplingParams::default()
548 };
549 let mut sampler = Sampler::new(1);
550 let mut counts = [0usize; 3];
551 for _ in 0..500 {
552 counts[sampler.sample(&logits, ¶ms, &[1])] += 1;
553 }
554 assert!(
555 counts[1] < 250,
556 "presence_penalty should discourage token 1; counts={counts:?}"
557 );
558
559 let params = SamplingParams {
560 temperature: 1.0,
561 presence_penalty: 0.0,
562 frequency_penalty: 10.0,
563 ..SamplingParams::default()
564 };
565 let mut sampler = Sampler::new(2);
566 counts = [0; 3];
567 for _ in 0..500 {
568 counts[sampler.sample(&logits, ¶ms, &[1, 1, 1])] += 1;
569 }
570 assert!(
571 counts[1] < 250,
572 "frequency_penalty should discourage repeated token 1; counts={counts:?}"
573 );
574 }
575
576 #[test]
577 fn repetition_penalty_reduces_probability_of_recently_seen_token() {
578 let logits = vec![0.0, 5.0, 0.0];
579 let params = SamplingParams {
580 temperature: 1.0,
581 repetition_penalty: 1000.0,
582 ..SamplingParams::default()
583 };
584 let mut sampler = Sampler::new(3);
585 let mut counts = [0usize; 3];
586 for _ in 0..500 {
587 counts[sampler.sample(&logits, ¶ms, &[1])] += 1;
588 }
589 assert!(
590 counts[1] < 250,
591 "heavily penalizing token 1 (already in history) should make it far less likely than its raw logit alone would suggest; got counts={counts:?}"
592 );
593 }
594
595 #[test]
596 fn low_seeds_do_not_bias_the_first_draw() {
597 // Every generation seeds a fresh `Sampler` (the server does it
598 // per request, from the caller's `seed`), so the FIRST draw off
599 // a freshly seeded generator is the one users actually see.
600 // Plain xorshift64 returns its own state, so seeds 1..4000 all
601 // produced a first draw in the bottom eighth of [0, 1) -- the
602 // first sampled token of every seeded request came off the
603 // bottom of the CDF.
604 let vocab = 8;
605 let logits = vec![0.0f32; vocab];
606 let params = SamplingParams {
607 temperature: 1.0,
608 ..SamplingParams::default()
609 };
610 let seeds = 4_000u64;
611 let mut counts = vec![0usize; vocab];
612 for seed in 1..=seeds {
613 counts[Sampler::new(seed).sample(&logits, ¶ms, &[])] += 1;
614 }
615 let expected = seeds as f64 / vocab as f64;
616 for (token, &c) in counts.iter().enumerate() {
617 assert!(
618 (c as f64 - expected).abs() < expected * 0.25,
619 "uniform logits: token {token} came up {c} times across {seeds} seeds, \
620 expected about {expected:.0} (counts={counts:?})"
621 );
622 }
623 }
624
625 #[test]
626 fn the_published_distribution_is_the_one_sample_actually_draws_from() {
627 // `sampling_distribution` is load-bearing for lossless
628 // speculative verification: if it disagreed with what `sample`
629 // draws from, every accept/reject decision would be measured
630 // against the wrong target. Check them against each other
631 // empirically, with filters on so the two code paths have
632 // something to disagree about.
633 let logits = vec![0.4, 2.0, -1.0, 1.2, 0.9, -0.3];
634 let params = SamplingParams {
635 temperature: 0.8,
636 top_p: 0.9,
637 top_k: 4,
638 repetition_penalty: 1.3,
639 ..SamplingParams::default()
640 };
641 let history = [1usize, 4];
642 let claimed = sampling_distribution(&logits, ¶ms, &history);
643 assert!((claimed.iter().sum::<f32>() - 1.0).abs() < 1e-5);
644
645 let draws = 100_000;
646 let mut counts = vec![0usize; logits.len()];
647 let mut sampler = Sampler::new(0xC0FFEE);
648 for _ in 0..draws {
649 counts[sampler.sample(&logits, ¶ms, &history)] += 1;
650 }
651 for (i, &c) in counts.iter().enumerate() {
652 let empirical = c as f64 / draws as f64;
653 assert!(
654 (empirical - claimed[i] as f64).abs() < 0.01,
655 "token {i}: sample() draws it {empirical:.4} of the time but \
656 sampling_distribution claims {:.4}",
657 claimed[i]
658 );
659 }
660 }
661
662 #[test]
663 fn greedy_is_published_as_a_point_mass_not_a_special_case() {
664 let logits = vec![0.1, 0.9, 0.3, -0.2];
665 let probs = sampling_distribution(&logits, &SamplingParams::default(), &[]);
666 assert_eq!(probs, vec![0.0, 1.0, 0.0, 0.0]);
667 // Penalties still apply at temperature 0, so the point mass
668 // moves with them.
669 let penalized = sampling_distribution(
670 &logits,
671 &SamplingParams {
672 repetition_penalty: 100.0,
673 ..SamplingParams::default()
674 },
675 &[1],
676 );
677 assert_eq!(penalized[1], 0.0);
678 assert_eq!(penalized.iter().sum::<f32>(), 1.0);
679 }
680
681 #[test]
682 fn degenerate_all_zero_probability_falls_back_to_greedy() {
683 // top_k=1 combined with a top_p that would exclude even that
684 // one surviving token is a contradictory/degenerate
685 // configuration; must not panic or sample index 0 blindly.
686 let logits = vec![0.1, 0.9, 0.3, -0.2];
687 let params = SamplingParams {
688 temperature: 1.0,
689 top_k: 1,
690 top_p: 1.0,
691 ..SamplingParams::default()
692 };
693 let mut sampler = Sampler::new(1);
694 assert_eq!(sampler.sample(&logits, ¶ms, &[]), 1);
695 }
696
697 /// Only the keys the file actually carries become a recommendation.
698 ///
699 /// **This test fails if an absent key is filled with a house
700 /// default** (the naive reading, and what HF's own
701 /// `GenerationConfig` object does): `top_p` and `top_k` would come
702 /// back as `Some(1.0)` / `Some(0)` and would then override whatever
703 /// the server itself defaults to, for values this checkpoint never
704 /// expressed.
705 #[test]
706 fn an_absent_generation_config_key_stays_absent_rather_than_taking_a_default() {
707 let recommended = RecommendedSampling::from_generation_config(r#"{"temperature": 0.6}"#);
708 assert_eq!(recommended.temperature, Some(0.6));
709 assert_eq!(recommended.top_p, None, "top_p was not in the file");
710 assert_eq!(recommended.top_k, None, "top_k was not in the file");
711 // An explicit JSON null is silence too (the reference's
712 // `if val is not None`).
713 let nulled = RecommendedSampling::from_generation_config(r#"{"top_p": null}"#);
714 assert_eq!(nulled, RecommendedSampling::default());
715 }
716
717 /// A reasoning checkpoint's full recommendation survives intact --
718 /// the case the whole path exists for (Qwen3.5: temp 1.0, top_k 20,
719 /// top_p 0.95).
720 #[test]
721 fn every_generation_config_key_present_is_recommended() {
722 let recommended = RecommendedSampling::from_generation_config(
723 r#"{"do_sample": true, "temperature": 1.0, "top_k": 20, "top_p": 0.95}"#,
724 );
725 assert_eq!(
726 recommended,
727 RecommendedSampling {
728 temperature: Some(1.0),
729 top_p: Some(0.95),
730 top_k: Some(20),
731 }
732 );
733 }
734
735 /// `do_sample: false` recommends greedy, expressed as temperature 0
736 /// and *nothing else*: the top_k/top_p such a file also carries
737 /// describe a sampler it is asking not to be used, so returning them
738 /// would filter a distribution the model wants collapsed to its
739 /// argmax.
740 #[test]
741 fn do_sample_false_recommends_greedy_and_no_other_field() {
742 let recommended = RecommendedSampling::from_generation_config(
743 r#"{"do_sample": false, "temperature": 0.7, "top_k": 50, "top_p": 0.9}"#,
744 );
745 assert_eq!(recommended.temperature, Some(0.0));
746 assert_eq!(recommended.top_p, None);
747 assert_eq!(recommended.top_k, None);
748 }
749
750 /// A sidecar that does not parse must not be able to change how the
751 /// model is sampled.
752 #[test]
753 fn a_malformed_generation_config_recommends_nothing() {
754 for text in ["", "not json", "[1, 2, 3]", "null"] {
755 assert!(
756 RecommendedSampling::from_generation_config(text).is_empty(),
757 "{text:?} must recommend nothing"
758 );
759 }
760 }
761
762 /// Precedence: the request wins over the checkpoint, and the
763 /// checkpoint only fills what the request left unset. An explicit
764 /// `temperature: 0` from a client must stay reachable on a model
765 /// that recommends 1.0.
766 #[test]
767 fn a_request_outranks_the_recommendation_which_outranks_the_framework_default() {
768 let recommended = RecommendedSampling {
769 temperature: Some(1.0),
770 top_p: Some(0.95),
771 top_k: Some(20),
772 };
773 let resolved = recommended.resolve(
774 RequestedSampling {
775 temperature: Some(0.0),
776 ..RequestedSampling::default()
777 },
778 SamplingParams::default(),
779 );
780 assert_eq!(resolved.temperature, 0.0, "the request asked for greedy");
781 assert_eq!(resolved.top_p, 0.95, "the request said nothing about top_p");
782 assert_eq!(resolved.top_k, 20, "the request said nothing about top_k");
783 // Penalties are never recommended, only carried through.
784 assert_eq!(resolved.repetition_penalty, 1.0);
785 }
786
787 /// A checkpoint that recommends nothing must leave ferrox's existing
788 /// behaviour bit-identical: greedy, unfiltered, exactly
789 /// `SamplingParams::default()`.
790 #[test]
791 fn a_checkpoint_that_recommends_nothing_leaves_the_framework_defaults_alone() {
792 let resolved = RecommendedSampling::default()
793 .resolve(RequestedSampling::default(), SamplingParams::default());
794 let default = SamplingParams::default();
795 assert_eq!(resolved.temperature, default.temperature);
796 assert_eq!(resolved.top_p, default.top_p);
797 assert_eq!(resolved.top_k, default.top_k);
798 }
799
800 /// A model directory with no `generation_config.json` recommends
801 /// nothing rather than failing: the absence of a recommendation is
802 /// the normal case for most checkpoints.
803 #[test]
804 fn a_model_directory_without_a_generation_config_recommends_nothing() {
805 let dir = std::env::temp_dir().join(format!(
806 "ferrox_test_no_generation_config_{}",
807 std::process::id()
808 ));
809 std::fs::create_dir_all(&dir).unwrap();
810 assert!(RecommendedSampling::from_model_dir(&dir).is_empty());
811 std::fs::remove_dir_all(&dir).ok();
812 }
813
814 /// The sidecar is read from the directory beside the weights, the
815 /// same place HF's `GenerationConfig.from_pretrained` looks.
816 #[test]
817 fn a_model_directory_generation_config_is_read_from_beside_the_weights() {
818 let dir = std::env::temp_dir().join(format!(
819 "ferrox_test_generation_config_dir_{}",
820 std::process::id()
821 ));
822 std::fs::create_dir_all(&dir).unwrap();
823 std::fs::write(
824 dir.join("generation_config.json"),
825 r#"{"temperature": 0.6, "top_p": 0.95}"#,
826 )
827 .unwrap();
828 let recommended = RecommendedSampling::from_model_dir(&dir);
829 std::fs::remove_dir_all(&dir).ok();
830 assert_eq!(recommended.temperature, Some(0.6));
831 assert_eq!(recommended.top_p, Some(0.95));
832 assert_eq!(recommended.top_k, None);
833 }
834}