use std::fmt;
use std::str::FromStr;
macro_rules! sampler_names {
($($variant:ident => $canonical:literal $(| $alias:literal)* ),+ $(,)?) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SamplerName {
$($variant),+
}
impl SamplerName {
pub const ALL: &'static [SamplerName] = &[$(SamplerName::$variant),+];
pub const fn as_str(self) -> &'static str {
match self {
$(SamplerName::$variant => $canonical),+
}
}
pub fn from_name(name: &str) -> Option<Self> {
match name {
$($canonical $(| $alias)* => Some(SamplerName::$variant),)+
_ => None,
}
}
}
};
}
sampler_names! {
Penalties => "penalties",
Dry => "dry",
TopNSigma => "top_n_sigma" | "top-n-sigma",
TopK => "top_k" | "top-k",
TypP => "typ_p" | "typ-p" | "typ" | "typical" | "typical_p" | "typical-p",
TopP => "top_p" | "top-p" | "nucleus",
MinP => "min_p" | "min-p",
Xtc => "xtc",
Temperature => "temperature" | "temp",
Mirostat => "mirostat",
Infill => "infill",
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ChainStep {
Penalties,
TopK,
TopP,
MinP,
Temperature,
}
impl ChainStep {
pub const fn name(self) -> SamplerName {
match self {
ChainStep::Penalties => SamplerName::Penalties,
ChainStep::TopK => SamplerName::TopK,
ChainStep::TopP => SamplerName::TopP,
ChainStep::MinP => SamplerName::MinP,
ChainStep::Temperature => SamplerName::Temperature,
}
}
}
impl SamplerName {
pub const fn implemented(self) -> Result<ChainStep, &'static str> {
match self {
SamplerName::Penalties => Ok(ChainStep::Penalties),
SamplerName::TopK => Ok(ChainStep::TopK),
SamplerName::TopP => Ok(ChainStep::TopP),
SamplerName::MinP => Ok(ChainStep::MinP),
SamplerName::Temperature => Ok(ChainStep::Temperature),
SamplerName::Dry => Err(
"the DRY repetition sampler is not implemented: it needs the n-gram \
breaker state llama.cpp keeps per sequence, which this engine has no \
equivalent of",
),
SamplerName::TypP => Err(
"locally typical sampling (`typ_p`) is not implemented: no filter in \
this engine ranks candidates by their distance from the distribution's \
entropy",
),
SamplerName::Xtc => Err(
"the XTC sampler is not implemented: it removes the TOP candidates with \
a probability, which is the only sampler here that would need its own \
draw off the RNG stream",
),
SamplerName::TopNSigma => Err(
"top-n-sigma truncation is not implemented: no filter here cuts on the \
standard deviation of the logits",
),
SamplerName::Mirostat => Err(
"mirostat is not implemented. It is not a chain member upstream either \
(llama.cpp spells it `--mirostat` and it REPLACES the chain), so there \
is no position in this order that would honour it",
),
SamplerName::Infill => Err(
"the infill sampler is not implemented: it needs the model's FIM tokens \
and a whitespace-aware candidate merge that this engine does not have",
),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SamplerOrderError {
Unknown(String),
Unimplemented {
name: &'static str,
reason: &'static str,
},
Duplicate(&'static str),
PenaltiesNotFirst,
TemperatureMissing,
Empty,
}
impl fmt::Display for SamplerOrderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SamplerOrderError::Unknown(name) => write!(
f,
"unknown sampler `{name}`. This engine accepts {}",
SamplerName::ALL
.iter()
.map(|n| n.as_str())
.collect::<Vec<_>>()
.join(", ")
),
SamplerOrderError::Unimplemented { name, reason } => write!(
f,
"sampler `{name}` is not implemented in ferrox: {reason}. It is refused \
rather than skipped, because a chain built without a sampler you asked \
for is a different sampler and you would have no way to tell. \
Implemented: {}",
SamplerOrder::implemented_names().join(", ")
),
SamplerOrderError::Duplicate(name) => write!(
f,
"sampler `{name}` is named twice; each sampler may appear at most once"
),
SamplerOrderError::PenaltiesNotFirst => write!(
f,
"`penalties` must be the FIRST sampler in the chain. ferrox applies the \
repetition / presence / frequency penalties to the whole vocabulary \
before the candidate list exists, so a `penalties` placed after a \
truncation filter would penalise a different candidate set than the one \
you asked for. Put it first, or leave it out to disable the penalties"
),
SamplerOrderError::TemperatureMissing => write!(
f,
"the chain must include `temperature`. ferrox decides greedy-versus-sampled \
from the temperature BEFORE the chain runs -- on Metal at `temp <= 0` the \
decoder folds the argmax into the GPU stack and hands the sampler a single \
precomputed token id, so there is no candidate list left for a chain \
without a temperature step to filter. Dropping `temperature` from the list \
buys nothing anyway: it is exactly `--temp 1.0` with the step kept"
),
SamplerOrderError::Empty => write!(
f,
"the sampler list is empty; name at least one of {}",
SamplerOrder::implemented_names().join(", ")
),
}
}
}
impl std::error::Error for SamplerOrderError {}
#[derive(Debug, Clone, Copy)]
pub struct SamplerOrder {
steps: [ChainStep; SamplerName::ALL.len()],
len: usize,
}
const DEFAULT_STEPS: [ChainStep; 5] = [
ChainStep::Penalties,
ChainStep::TopK,
ChainStep::TopP,
ChainStep::MinP,
ChainStep::Temperature,
];
impl Default for SamplerOrder {
fn default() -> Self {
let mut steps = [ChainStep::Temperature; SamplerName::ALL.len()];
steps[..DEFAULT_STEPS.len()].copy_from_slice(&DEFAULT_STEPS);
SamplerOrder {
steps,
len: DEFAULT_STEPS.len(),
}
}
}
impl SamplerOrder {
pub fn steps(&self) -> &[ChainStep] {
&self.steps[..self.len]
}
pub fn has_penalties(&self) -> bool {
self.steps().contains(&ChainStep::Penalties)
}
pub fn implemented_names() -> Vec<&'static str> {
SamplerName::ALL
.iter()
.filter(|n| n.implemented().is_ok())
.map(|n| n.as_str())
.collect()
}
pub fn from_names<I, S>(names: I) -> Result<Self, SamplerOrderError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut steps = [ChainStep::Temperature; SamplerName::ALL.len()];
let mut len = 0usize;
for raw in names {
let name = raw.as_ref().trim().to_ascii_lowercase();
let parsed = SamplerName::from_name(&name)
.ok_or_else(|| SamplerOrderError::Unknown(raw.as_ref().trim().to_string()))?;
let step = parsed
.implemented()
.map_err(|reason| SamplerOrderError::Unimplemented {
name: parsed.as_str(),
reason,
})?;
if steps[..len].contains(&step) {
return Err(SamplerOrderError::Duplicate(parsed.as_str()));
}
if step == ChainStep::Penalties && len > 0 {
return Err(SamplerOrderError::PenaltiesNotFirst);
}
steps[len] = step;
len += 1;
}
if len == 0 {
return Err(SamplerOrderError::Empty);
}
if !steps[..len].contains(&ChainStep::Temperature) {
return Err(SamplerOrderError::TemperatureMissing);
}
Ok(SamplerOrder { steps, len })
}
}
impl FromStr for SamplerOrder {
type Err = SamplerOrderError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.trim().is_empty() {
return Err(SamplerOrderError::Empty);
}
SamplerOrder::from_names(s.split(';'))
}
}
impl fmt::Display for SamplerOrder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut first = true;
for step in self.steps() {
if !first {
f.write_str(";")?;
}
first = false;
f.write_str(step.name().as_str())?;
}
Ok(())
}
}
impl PartialEq for SamplerOrder {
fn eq(&self, other: &Self) -> bool {
self.steps() == other.steps()
}
}
impl Eq for SamplerOrder {}
impl std::hash::Hash for SamplerOrder {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.steps().hash(state);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_default_chain_is_penalties_top_k_top_p_min_p_then_temperature() {
assert_eq!(
SamplerOrder::default().to_string(),
"penalties;top_k;top_p;min_p;temperature"
);
assert!(SamplerOrder::default().has_penalties());
}
#[test]
fn every_name_in_the_table_parses_back_to_itself() {
for &name in SamplerName::ALL {
assert_eq!(
SamplerName::from_name(name.as_str()),
Some(name),
"{name:?} does not round-trip through its own spelling"
);
}
let mut spellings: Vec<&str> = SamplerName::ALL.iter().map(|n| n.as_str()).collect();
spellings.sort_unstable();
let before = spellings.len();
spellings.dedup();
assert_eq!(before, spellings.len(), "two names share a spelling");
}
#[test]
fn every_implemented_name_round_trips_through_its_step() {
for &name in SamplerName::ALL {
if let Ok(step) = name.implemented() {
assert_eq!(step.name(), name, "{name:?} maps to a step named otherwise");
}
}
}
#[test]
fn llama_cpp_aliases_parse_to_the_canonical_name() {
for (alias, expected) in [
("top-k", SamplerName::TopK),
("top-p", SamplerName::TopP),
("nucleus", SamplerName::TopP),
("min-p", SamplerName::MinP),
("temp", SamplerName::Temperature),
("typical", SamplerName::TypP),
] {
assert_eq!(SamplerName::from_name(alias), Some(expected), "{alias}");
}
assert_eq!(
" Top_K ; TEMPERATURE ".parse::<SamplerOrder>().unwrap(),
SamplerOrder::from_names(["top_k", "temperature"]).unwrap()
);
}
#[test]
fn an_unknown_sampler_is_refused_by_name() {
let err = "top_k;top_kk;temperature"
.parse::<SamplerOrder>()
.expect_err("top_kk is not a sampler");
assert_eq!(err, SamplerOrderError::Unknown("top_kk".to_string()));
assert!(err.to_string().contains("top_kk"), "{err}");
}
#[test]
fn a_real_but_unimplemented_sampler_is_refused_with_its_reason() {
for name in ["dry", "xtc", "typ_p", "mirostat", "top_n_sigma", "infill"] {
let err = format!("top_k;{name};temperature")
.parse::<SamplerOrder>()
.expect_err(&format!("`{name}` must be refused, not skipped"));
assert!(
matches!(err, SamplerOrderError::Unimplemented { name: n, .. } if n == name),
"`{name}` was refused as {err:?}, which does not name it as a real \
llama.cpp sampler ferrox lacks"
);
}
for &name in SamplerName::ALL {
let probe: Vec<&str> = if name == SamplerName::Temperature {
vec!["temperature"]
} else {
vec![name.as_str(), "temperature"]
};
let accepted = SamplerOrder::from_names(probe).is_ok();
assert_eq!(
accepted,
name.implemented().is_ok(),
"{name:?}: the parser and `implemented()` disagree"
);
}
}
#[test]
fn an_unimplemented_sampler_names_itself_and_says_what_is_implemented() {
let err = "top_k;xtc".parse::<SamplerOrder>().expect_err("no xtc");
assert_eq!(
err,
SamplerOrderError::Unimplemented {
name: "xtc",
reason: SamplerName::Xtc.implemented().unwrap_err(),
}
);
let message = err.to_string();
assert!(message.contains("`xtc`"), "{message}");
assert!(
message.contains("top_k"),
"must list what IS there: {message}"
);
assert!(!message.contains("unknown"), "{message}");
}
#[test]
fn the_same_sampler_twice_is_refused() {
assert_eq!(
"top_k;top_p;top_k"
.parse::<SamplerOrder>()
.expect_err("dup"),
SamplerOrderError::Duplicate("top_k")
);
assert_eq!(
"top-k;top_k".parse::<SamplerOrder>().expect_err("dup"),
SamplerOrderError::Duplicate("top_k")
);
}
#[test]
fn penalties_anywhere_but_first_is_refused() {
assert_eq!(
"top_k;penalties".parse::<SamplerOrder>().expect_err("late"),
SamplerOrderError::PenaltiesNotFirst
);
assert!("penalties;top_k;temperature"
.parse::<SamplerOrder>()
.is_ok());
let no_penalties = "top_k;temperature".parse::<SamplerOrder>().unwrap();
assert!(!no_penalties.has_penalties());
}
#[test]
fn an_empty_chain_is_refused_rather_than_read_as_the_default() {
assert_eq!(
"".parse::<SamplerOrder>().expect_err("empty"),
SamplerOrderError::Empty
);
assert_eq!(
" ".parse::<SamplerOrder>().expect_err("blank"),
SamplerOrderError::Empty
);
assert_eq!(
"top_k;;top_p".parse::<SamplerOrder>().expect_err("stray"),
SamplerOrderError::Unknown(String::new())
);
}
#[test]
fn two_chains_with_the_same_steps_are_equal_and_hash_alike() {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let a = "top_k;temperature".parse::<SamplerOrder>().unwrap();
let b = SamplerOrder::from_names(["top-k", "temp"]).unwrap();
assert_eq!(a, b);
let hash = |o: &SamplerOrder| {
let mut h = DefaultHasher::new();
o.hash(&mut h);
h.finish()
};
assert_eq!(hash(&a), hash(&b));
let reversed = "temperature;top_k".parse::<SamplerOrder>().unwrap();
assert_ne!(a, reversed);
assert_ne!(hash(&a), hash(&reversed));
}
#[test]
fn the_printed_chain_parses_back_to_the_same_chain() {
let default = SamplerOrder::default();
assert_eq!(
default.to_string().parse::<SamplerOrder>().unwrap(),
default
);
}
}