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}