ferrox_models/sampler_order.rs
1//! The ORDER the sampler chain runs in, as llama.cpp's `--samplers`
2//! spells it, and the refusal for every sampler ferrox does not have.
3//!
4//! # Why the order is a parameter and not a constant
5//!
6//! llama.cpp lets a caller reorder its chain (`--samplers`,
7//! `--sampler-seq`, and the server's `samplers` request field). ferrox
8//! ran one fixed order, so a command that worked upstream either could
9//! not be expressed here or -- worse -- was accepted with the field
10//! dropped and answered under a different chain.
11//!
12//! The order is not cosmetic. `sampler_chain` models the SHRINKING
13//! candidate list llama.cpp passes down the chain, and each filter
14//! renormalises over the survivors, so moving one step changes which
15//! candidates exist for the next. ferrox has already shipped the bug:
16//! temperature used to run FIRST, and top-p then summed probabilities
17//! temperature had already reshaped, keeping a different candidate set
18//! for identical flags.
19//!
20//! # The one table
21//!
22//! A list of names in the CLI and a second list in the server is this
23//! repo's dominant defect shape: two structures that must agree with
24//! nothing enforcing it. So there is exactly one list, in the
25//! `sampler_names!` invocation below, and the enum, the canonical
26//! spellings, the alias table and the parser are all generated from it.
27//! Adding a name is one row.
28//!
29//! [`SamplerName::implemented`] is the second half of the guarantee: an
30//! EXHAUSTIVE `match`, no `..`, that must say for every name whether
31//! ferrox runs it or why it does not. A name added to the table without
32//! a verdict does not compile.
33//!
34//! # Partial support, stated
35//!
36//! ferrox implements five of llama.cpp's samplers. The rest are named
37//! in the table purely so that asking for one is a refusal that says
38//! WHICH sampler is missing, rather than an unknown-name error or, far
39//! worse, a chain quietly built without it. A caller who asked for
40//! `xtc` and was served a chain with no XTC in it got a different
41//! sampler than they requested and no way to tell.
42
43use std::fmt;
44use std::str::FromStr;
45
46/// Generates the name table: the enum, `ALL`, the canonical spelling of
47/// each variant, and the parser -- from ONE list of rows.
48///
49/// Each row is `Variant => "canonical" | "alias" | ...`. The aliases are
50/// llama.cpp's own (`common_sampler_type_from_name`'s
51/// `sampler_alt_name_map`), so a command line that works upstream parses
52/// here.
53macro_rules! sampler_names {
54 ($($variant:ident => $canonical:literal $(| $alias:literal)* ),+ $(,)?) => {
55 /// Every sampler name llama.cpp's `--samplers` accepts.
56 ///
57 /// Membership here says the name is REAL, not that ferrox
58 /// implements it; [`SamplerName::implemented`] decides that.
59 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
60 pub enum SamplerName {
61 $($variant),+
62 }
63
64 impl SamplerName {
65 /// Every name, in llama.cpp's own default chain order.
66 pub const ALL: &'static [SamplerName] = &[$(SamplerName::$variant),+];
67
68 /// llama.cpp's canonical spelling, which is also what
69 /// [`SamplerOrder`] prints.
70 pub const fn as_str(self) -> &'static str {
71 match self {
72 $(SamplerName::$variant => $canonical),+
73 }
74 }
75
76 /// One name, canonical spelling or llama.cpp alias.
77 ///
78 /// `name` is expected already trimmed and lowercased; see
79 /// [`SamplerOrder::from_names`].
80 pub fn from_name(name: &str) -> Option<Self> {
81 match name {
82 $($canonical $(| $alias)* => Some(SamplerName::$variant),)+
83 _ => None,
84 }
85 }
86 }
87 };
88}
89
90sampler_names! {
91 Penalties => "penalties",
92 Dry => "dry",
93 TopNSigma => "top_n_sigma" | "top-n-sigma",
94 TopK => "top_k" | "top-k",
95 TypP => "typ_p" | "typ-p" | "typ" | "typical" | "typical_p" | "typical-p",
96 TopP => "top_p" | "top-p" | "nucleus",
97 MinP => "min_p" | "min-p",
98 Xtc => "xtc",
99 Temperature => "temperature" | "temp",
100 Mirostat => "mirostat",
101 Infill => "infill",
102}
103
104/// A step the ferrox chain can actually run.
105///
106/// [`SamplerOrder`] holds these rather than [`SamplerName`]s so that an
107/// unimplemented sampler is unrepresentable once the order exists: the
108/// applier's `match` is total over what it can be handed, with no
109/// `unreachable!` standing in for a check that happened somewhere else.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
111pub enum ChainStep {
112 /// The repetition / presence / frequency penalties.
113 Penalties,
114 TopK,
115 TopP,
116 MinP,
117 Temperature,
118}
119
120impl ChainStep {
121 /// The name this step is spelled with. Exhaustive, so a step added
122 /// without a name does not compile.
123 pub const fn name(self) -> SamplerName {
124 match self {
125 ChainStep::Penalties => SamplerName::Penalties,
126 ChainStep::TopK => SamplerName::TopK,
127 ChainStep::TopP => SamplerName::TopP,
128 ChainStep::MinP => SamplerName::MinP,
129 ChainStep::Temperature => SamplerName::Temperature,
130 }
131 }
132}
133
134impl SamplerName {
135 /// The step ferrox runs for this name, or the reason it has none.
136 ///
137 /// EXHAUSTIVE ON PURPOSE, with no `..`: a name added to the table
138 /// above stops this crate compiling here until someone states
139 /// whether ferrox implements it. The alternative -- a `_ => Err(..)`
140 /// arm -- would let a sampler ferrox *does* have be added to the
141 /// table and silently refused, which is the same class of silence
142 /// this module exists to close.
143 pub const fn implemented(self) -> Result<ChainStep, &'static str> {
144 match self {
145 SamplerName::Penalties => Ok(ChainStep::Penalties),
146 SamplerName::TopK => Ok(ChainStep::TopK),
147 SamplerName::TopP => Ok(ChainStep::TopP),
148 SamplerName::MinP => Ok(ChainStep::MinP),
149 SamplerName::Temperature => Ok(ChainStep::Temperature),
150 SamplerName::Dry => Err(
151 "the DRY repetition sampler is not implemented: it needs the n-gram \
152 breaker state llama.cpp keeps per sequence, which this engine has no \
153 equivalent of",
154 ),
155 SamplerName::TypP => Err(
156 "locally typical sampling (`typ_p`) is not implemented: no filter in \
157 this engine ranks candidates by their distance from the distribution's \
158 entropy",
159 ),
160 SamplerName::Xtc => Err(
161 "the XTC sampler is not implemented: it removes the TOP candidates with \
162 a probability, which is the only sampler here that would need its own \
163 draw off the RNG stream",
164 ),
165 SamplerName::TopNSigma => Err(
166 "top-n-sigma truncation is not implemented: no filter here cuts on the \
167 standard deviation of the logits",
168 ),
169 SamplerName::Mirostat => Err(
170 "mirostat is not implemented. It is not a chain member upstream either \
171 (llama.cpp spells it `--mirostat` and it REPLACES the chain), so there \
172 is no position in this order that would honour it",
173 ),
174 SamplerName::Infill => Err(
175 "the infill sampler is not implemented: it needs the model's FIM tokens \
176 and a whitespace-aware candidate merge that this engine does not have",
177 ),
178 }
179 }
180}
181
182/// Why a caller-supplied sampler order was refused.
183///
184/// Every variant names the sampler it is about. A refusal that said only
185/// "bad sampler list" would leave the caller guessing which of five
186/// names this engine disliked.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub enum SamplerOrderError {
189 /// No sampler by this name exists in llama.cpp either.
190 Unknown(String),
191 /// A real llama.cpp sampler that ferrox does not implement.
192 Unimplemented {
193 name: &'static str,
194 reason: &'static str,
195 },
196 /// The same sampler named twice.
197 Duplicate(&'static str),
198 /// `penalties` somewhere other than the front. See
199 /// [`SamplerOrder::from_names`].
200 PenaltiesNotFirst,
201 /// A chain with no `temperature` step. See
202 /// [`SamplerOrder::from_names`].
203 TemperatureMissing,
204 /// An empty chain: `--samplers ""`.
205 Empty,
206}
207
208impl fmt::Display for SamplerOrderError {
209 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210 match self {
211 SamplerOrderError::Unknown(name) => write!(
212 f,
213 "unknown sampler `{name}`. This engine accepts {}",
214 SamplerName::ALL
215 .iter()
216 .map(|n| n.as_str())
217 .collect::<Vec<_>>()
218 .join(", ")
219 ),
220 SamplerOrderError::Unimplemented { name, reason } => write!(
221 f,
222 "sampler `{name}` is not implemented in ferrox: {reason}. It is refused \
223 rather than skipped, because a chain built without a sampler you asked \
224 for is a different sampler and you would have no way to tell. \
225 Implemented: {}",
226 SamplerOrder::implemented_names().join(", ")
227 ),
228 SamplerOrderError::Duplicate(name) => write!(
229 f,
230 "sampler `{name}` is named twice; each sampler may appear at most once"
231 ),
232 SamplerOrderError::PenaltiesNotFirst => write!(
233 f,
234 "`penalties` must be the FIRST sampler in the chain. ferrox applies the \
235 repetition / presence / frequency penalties to the whole vocabulary \
236 before the candidate list exists, so a `penalties` placed after a \
237 truncation filter would penalise a different candidate set than the one \
238 you asked for. Put it first, or leave it out to disable the penalties"
239 ),
240 SamplerOrderError::TemperatureMissing => write!(
241 f,
242 "the chain must include `temperature`. ferrox decides greedy-versus-sampled \
243 from the temperature BEFORE the chain runs -- on Metal at `temp <= 0` the \
244 decoder folds the argmax into the GPU stack and hands the sampler a single \
245 precomputed token id, so there is no candidate list left for a chain \
246 without a temperature step to filter. Dropping `temperature` from the list \
247 buys nothing anyway: it is exactly `--temp 1.0` with the step kept"
248 ),
249 SamplerOrderError::Empty => write!(
250 f,
251 "the sampler list is empty; name at least one of {}",
252 SamplerOrder::implemented_names().join(", ")
253 ),
254 }
255 }
256}
257
258impl std::error::Error for SamplerOrderError {}
259
260/// A validated sampler chain: the steps ferrox will run, in the order it
261/// will run them.
262///
263/// Fixed-capacity and `Copy` because [`crate::sampling::SamplingParams`]
264/// is cloned per request and read per token; a `Vec` here would be an
265/// allocation on that path for at most
266/// [`SamplerName::ALL`]`.len()` entries.
267#[derive(Debug, Clone, Copy)]
268pub struct SamplerOrder {
269 steps: [ChainStep; SamplerName::ALL.len()],
270 len: usize,
271}
272
273/// ferrox's chain, which is llama.cpp's default chain restricted to the
274/// samplers ferrox has: penalties, top-k, top-p, min-p, and
275/// **temperature last**.
276///
277/// This is the single definition of "the default". Changing it changes
278/// every run that did not pass `--samplers`, which
279/// `sampling::tests::the_default_order_is_the_chain_ferrox_already_ran`
280/// exists to catch.
281const DEFAULT_STEPS: [ChainStep; 5] = [
282 ChainStep::Penalties,
283 ChainStep::TopK,
284 ChainStep::TopP,
285 ChainStep::MinP,
286 ChainStep::Temperature,
287];
288
289impl Default for SamplerOrder {
290 fn default() -> Self {
291 let mut steps = [ChainStep::Temperature; SamplerName::ALL.len()];
292 steps[..DEFAULT_STEPS.len()].copy_from_slice(&DEFAULT_STEPS);
293 SamplerOrder {
294 steps,
295 len: DEFAULT_STEPS.len(),
296 }
297 }
298}
299
300impl SamplerOrder {
301 /// The steps, in order.
302 pub fn steps(&self) -> &[ChainStep] {
303 &self.steps[..self.len]
304 }
305
306 /// Whether the penalties are part of this chain. `false` means the
307 /// caller left `penalties` out, which llama.cpp reads as "do not
308 /// penalise", so ferrox must not penalise either.
309 pub fn has_penalties(&self) -> bool {
310 self.steps().contains(&ChainStep::Penalties)
311 }
312
313 /// The canonical spellings of every sampler this engine implements,
314 /// derived from the one table rather than restated.
315 pub fn implemented_names() -> Vec<&'static str> {
316 SamplerName::ALL
317 .iter()
318 .filter(|n| n.implemented().is_ok())
319 .map(|n| n.as_str())
320 .collect()
321 }
322
323 /// Parse a chain from already-split names.
324 ///
325 /// Each name is trimmed and lowercased first: a spelling is not a
326 /// semantic, so `Top_K` cannot mean anything but `top_k` and
327 /// refusing it would be a false refusal.
328 ///
329 /// Four things are refused, all BY NAME:
330 ///
331 /// * a name llama.cpp does not define either;
332 /// * a real llama.cpp sampler ferrox does not implement;
333 /// * the same sampler twice, which llama.cpp tolerates and which
334 /// here would silently mean "run it once" for the idempotent
335 /// filters and "square it" for temperature;
336 /// * `penalties` anywhere but first -- see
337 /// [`SamplerOrderError::PenaltiesNotFirst`];
338 /// * a chain with no `temperature` -- see
339 /// [`SamplerOrderError::TemperatureMissing`].
340 pub fn from_names<I, S>(names: I) -> Result<Self, SamplerOrderError>
341 where
342 I: IntoIterator<Item = S>,
343 S: AsRef<str>,
344 {
345 let mut steps = [ChainStep::Temperature; SamplerName::ALL.len()];
346 let mut len = 0usize;
347 for raw in names {
348 let name = raw.as_ref().trim().to_ascii_lowercase();
349 let parsed = SamplerName::from_name(&name)
350 .ok_or_else(|| SamplerOrderError::Unknown(raw.as_ref().trim().to_string()))?;
351 let step = parsed
352 .implemented()
353 .map_err(|reason| SamplerOrderError::Unimplemented {
354 name: parsed.as_str(),
355 reason,
356 })?;
357 if steps[..len].contains(&step) {
358 return Err(SamplerOrderError::Duplicate(parsed.as_str()));
359 }
360 if step == ChainStep::Penalties && len > 0 {
361 return Err(SamplerOrderError::PenaltiesNotFirst);
362 }
363 // `len` cannot reach the capacity: the array is as long as
364 // the whole name table and duplicates are refused above.
365 steps[len] = step;
366 len += 1;
367 }
368 if len == 0 {
369 return Err(SamplerOrderError::Empty);
370 }
371 if !steps[..len].contains(&ChainStep::Temperature) {
372 return Err(SamplerOrderError::TemperatureMissing);
373 }
374 Ok(SamplerOrder { steps, len })
375 }
376}
377
378/// `;`-separated, exactly as llama.cpp's `--samplers` takes it.
379impl FromStr for SamplerOrder {
380 type Err = SamplerOrderError;
381
382 fn from_str(s: &str) -> Result<Self, Self::Err> {
383 if s.trim().is_empty() {
384 return Err(SamplerOrderError::Empty);
385 }
386 SamplerOrder::from_names(s.split(';'))
387 }
388}
389
390/// Round-trips through [`FromStr`], so the CLI can print the default it
391/// applied and have that string mean the same chain.
392impl fmt::Display for SamplerOrder {
393 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
394 let mut first = true;
395 for step in self.steps() {
396 if !first {
397 f.write_str(";")?;
398 }
399 first = false;
400 f.write_str(step.name().as_str())?;
401 }
402 Ok(())
403 }
404}
405
406/// Equality and hashing are over the LIVE steps only. The backing array
407/// is fixed-capacity, so the slots past `len` hold filler that two equal
408/// chains need not agree about -- and the response cache keys on this,
409/// where a spurious inequality would just miss and a spurious equality
410/// would serve one caller's answer to another.
411impl PartialEq for SamplerOrder {
412 fn eq(&self, other: &Self) -> bool {
413 self.steps() == other.steps()
414 }
415}
416
417impl Eq for SamplerOrder {}
418
419impl std::hash::Hash for SamplerOrder {
420 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
421 self.steps().hash(state);
422 }
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428
429 /// The default chain is the one ferrox already ran, spelled out.
430 ///
431 /// The distribution-level proof is
432 /// `sampling::tests::the_default_order_is_the_chain_ferrox_already_ran`;
433 /// this is the cheap statement of the same fact, so a reordering of
434 /// `DEFAULT_STEPS` is visible in one line of diff.
435 #[test]
436 fn the_default_chain_is_penalties_top_k_top_p_min_p_then_temperature() {
437 assert_eq!(
438 SamplerOrder::default().to_string(),
439 "penalties;top_k;top_p;min_p;temperature"
440 );
441 assert!(SamplerOrder::default().has_penalties());
442 }
443
444 /// Every name in the generated table parses back to itself, which is
445 /// what makes `ALL`, `as_str` and `from_name` one table rather than
446 /// three that must agree.
447 #[test]
448 fn every_name_in_the_table_parses_back_to_itself() {
449 for &name in SamplerName::ALL {
450 assert_eq!(
451 SamplerName::from_name(name.as_str()),
452 Some(name),
453 "{name:?} does not round-trip through its own spelling"
454 );
455 }
456 // And no two variants share a spelling, which would make the
457 // round-trip above pass while one name was unreachable.
458 let mut spellings: Vec<&str> = SamplerName::ALL.iter().map(|n| n.as_str()).collect();
459 spellings.sort_unstable();
460 let before = spellings.len();
461 spellings.dedup();
462 assert_eq!(before, spellings.len(), "two names share a spelling");
463 }
464
465 /// A step and its name agree in both directions, so the applier's
466 /// `ChainStep` and the caller's `SamplerName` cannot drift.
467 #[test]
468 fn every_implemented_name_round_trips_through_its_step() {
469 for &name in SamplerName::ALL {
470 if let Ok(step) = name.implemented() {
471 assert_eq!(step.name(), name, "{name:?} maps to a step named otherwise");
472 }
473 }
474 }
475
476 /// llama.cpp's own aliases parse, so a command line that works
477 /// upstream works here.
478 #[test]
479 fn llama_cpp_aliases_parse_to_the_canonical_name() {
480 for (alias, expected) in [
481 ("top-k", SamplerName::TopK),
482 ("top-p", SamplerName::TopP),
483 ("nucleus", SamplerName::TopP),
484 ("min-p", SamplerName::MinP),
485 ("temp", SamplerName::Temperature),
486 ("typical", SamplerName::TypP),
487 ] {
488 assert_eq!(SamplerName::from_name(alias), Some(expected), "{alias}");
489 }
490 // Case and surrounding whitespace are spelling, not meaning.
491 assert_eq!(
492 " Top_K ; TEMPERATURE ".parse::<SamplerOrder>().unwrap(),
493 SamplerOrder::from_names(["top_k", "temperature"]).unwrap()
494 );
495 }
496
497 /// A name nobody defines is refused, and the refusal REPEATS THE
498 /// NAME. A caller who typed `top_kk` must not have to guess which of
499 /// their five names this engine disliked.
500 #[test]
501 fn an_unknown_sampler_is_refused_by_name() {
502 let err = "top_k;top_kk;temperature"
503 .parse::<SamplerOrder>()
504 .expect_err("top_kk is not a sampler");
505 assert_eq!(err, SamplerOrderError::Unknown("top_kk".to_string()));
506 assert!(err.to_string().contains("top_kk"), "{err}");
507 }
508
509 /// The samplers llama.cpp has and ferrox does not are refused BY
510 /// NAME, with the reason, rather than dropped from the chain.
511 ///
512 /// A caller who asked for `xtc` and was handed a chain without it
513 /// was given a different sampler and served a 200. This is the whole
514 /// reason those names are in the table at all.
515 #[test]
516 fn a_real_but_unimplemented_sampler_is_refused_with_its_reason() {
517 for name in ["dry", "xtc", "typ_p", "mirostat", "top_n_sigma", "infill"] {
518 let err = format!("top_k;{name};temperature")
519 .parse::<SamplerOrder>()
520 .expect_err(&format!("`{name}` must be refused, not skipped"));
521 assert!(
522 matches!(err, SamplerOrderError::Unimplemented { name: n, .. } if n == name),
523 "`{name}` was refused as {err:?}, which does not name it as a real \
524 llama.cpp sampler ferrox lacks"
525 );
526 }
527 // Every name the table calls unimplemented is refused, and every
528 // name it calls implemented is accepted -- so the table and the
529 // parser cannot disagree about which half a name is in.
530 for &name in SamplerName::ALL {
531 // `temperature` is required in every chain, so a one-name
532 // probe for anything else has to carry it.
533 let probe: Vec<&str> = if name == SamplerName::Temperature {
534 vec!["temperature"]
535 } else {
536 vec![name.as_str(), "temperature"]
537 };
538 let accepted = SamplerOrder::from_names(probe).is_ok();
539 assert_eq!(
540 accepted,
541 name.implemented().is_ok(),
542 "{name:?}: the parser and `implemented()` disagree"
543 );
544 }
545 }
546
547 /// Same as above, read off the error rather than the panic, so the
548 /// MESSAGE is asserted and not just the failure.
549 #[test]
550 fn an_unimplemented_sampler_names_itself_and_says_what_is_implemented() {
551 let err = "top_k;xtc".parse::<SamplerOrder>().expect_err("no xtc");
552 assert_eq!(
553 err,
554 SamplerOrderError::Unimplemented {
555 name: "xtc",
556 reason: SamplerName::Xtc.implemented().unwrap_err(),
557 }
558 );
559 let message = err.to_string();
560 assert!(message.contains("`xtc`"), "{message}");
561 assert!(
562 message.contains("top_k"),
563 "must list what IS there: {message}"
564 );
565 // Unknown and unimplemented are different verdicts: one says
566 // "no such sampler", the other "that sampler exists and ferrox
567 // does not have it". Collapsing them would tell a caller their
568 // valid llama.cpp flag was a typo.
569 assert!(!message.contains("unknown"), "{message}");
570 }
571
572 #[test]
573 fn the_same_sampler_twice_is_refused() {
574 assert_eq!(
575 "top_k;top_p;top_k"
576 .parse::<SamplerOrder>()
577 .expect_err("dup"),
578 SamplerOrderError::Duplicate("top_k")
579 );
580 // An alias is the same sampler.
581 assert_eq!(
582 "top-k;top_k".parse::<SamplerOrder>().expect_err("dup"),
583 SamplerOrderError::Duplicate("top_k")
584 );
585 }
586
587 /// `penalties` after a truncation filter would penalise a candidate
588 /// set that had already been cut, which is not what ferrox runs, so
589 /// it is refused instead of quietly re-interpreted.
590 #[test]
591 fn penalties_anywhere_but_first_is_refused() {
592 assert_eq!(
593 "top_k;penalties".parse::<SamplerOrder>().expect_err("late"),
594 SamplerOrderError::PenaltiesNotFirst
595 );
596 assert!("penalties;top_k;temperature"
597 .parse::<SamplerOrder>()
598 .is_ok());
599 // Absent is fine and means "do not penalise".
600 let no_penalties = "top_k;temperature".parse::<SamplerOrder>().unwrap();
601 assert!(!no_penalties.has_penalties());
602 }
603
604 #[test]
605 fn an_empty_chain_is_refused_rather_than_read_as_the_default() {
606 assert_eq!(
607 "".parse::<SamplerOrder>().expect_err("empty"),
608 SamplerOrderError::Empty
609 );
610 assert_eq!(
611 " ".parse::<SamplerOrder>().expect_err("blank"),
612 SamplerOrderError::Empty
613 );
614 // A stray separator is an empty NAME, which is unknown rather
615 // than an empty chain.
616 assert_eq!(
617 "top_k;;top_p".parse::<SamplerOrder>().expect_err("stray"),
618 SamplerOrderError::Unknown(String::new())
619 );
620 }
621
622 /// Equality ignores the fixed-capacity array's filler, or the
623 /// response cache would treat two identical chains as different keys
624 /// depending on how they were built.
625 #[test]
626 fn two_chains_with_the_same_steps_are_equal_and_hash_alike() {
627 use std::collections::hash_map::DefaultHasher;
628 use std::hash::{Hash, Hasher};
629
630 let a = "top_k;temperature".parse::<SamplerOrder>().unwrap();
631 let b = SamplerOrder::from_names(["top-k", "temp"]).unwrap();
632 assert_eq!(a, b);
633 let hash = |o: &SamplerOrder| {
634 let mut h = DefaultHasher::new();
635 o.hash(&mut h);
636 h.finish()
637 };
638 assert_eq!(hash(&a), hash(&b));
639 // And a reordering is NOT equal, or the cache key would serve
640 // one chain's answer for another's request.
641 let reversed = "temperature;top_k".parse::<SamplerOrder>().unwrap();
642 assert_ne!(a, reversed);
643 assert_ne!(hash(&a), hash(&reversed));
644 }
645
646 /// The default round-trips through its own printed form, so the
647 /// string a CLI banner shows can be pasted back into `--samplers`.
648 #[test]
649 fn the_printed_chain_parses_back_to_the_same_chain() {
650 let default = SamplerOrder::default();
651 assert_eq!(
652 default.to_string().parse::<SamplerOrder>().unwrap(),
653 default
654 );
655 }
656}