Skip to main content

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}