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