#![forbid(unsafe_code)]
#![warn(missing_docs)]
use std::fmt;
mod sha256;
pub const BUCKETS: u32 = 10_000;
#[derive(Debug, Clone, PartialEq)]
pub struct Params<'a> {
pub experiment_name: &'a str,
pub unique_id: &'a str,
pub treatment_split: f64,
pub exposure: f64,
pub treatment_variant_name: &'a str,
pub control_variant_name: &'a str,
pub ignore_variant_name: &'a str,
pub legacy_hashing: bool,
}
impl Default for Params<'_> {
fn default() -> Self {
Params {
experiment_name: "",
unique_id: "",
treatment_split: 50.0,
exposure: 100.0,
treatment_variant_name: "treatment",
control_variant_name: "control",
ignore_variant_name: "ignore",
legacy_hashing: false,
}
}
}
impl<'a> Params<'a> {
pub fn new(experiment_name: &'a str, unique_id: &'a str) -> Self {
Params {
experiment_name,
unique_id,
..Params::default()
}
}
pub fn treatment_split(mut self, percent: f64) -> Self {
self.treatment_split = percent;
self
}
pub fn exposure(mut self, percent: f64) -> Self {
self.exposure = percent;
self
}
pub fn treatment_variant_name(mut self, name: &'a str) -> Self {
self.treatment_variant_name = name;
self
}
pub fn control_variant_name(mut self, name: &'a str) -> Self {
self.control_variant_name = name;
self
}
pub fn ignore_variant_name(mut self, name: &'a str) -> Self {
self.ignore_variant_name = name;
self
}
pub fn legacy_hashing(mut self, enabled: bool) -> Self {
self.legacy_hashing = enabled;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Assignment {
pub experiment_name: String,
pub unique_id: String,
pub resolved_variant: String,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum Error {
InvalidTreatmentSplit(f64),
InvalidExposure(f64),
EmptyUniqueId,
EmptyExperimentName,
}
impl Error {
pub fn field(&self) -> &'static str {
match self {
Error::InvalidTreatmentSplit(_) => "treatment_split",
Error::InvalidExposure(_) => "exposure",
Error::EmptyUniqueId => "unique_id",
Error::EmptyExperimentName => "experiment_name",
}
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::InvalidTreatmentSplit(v) => write!(
f,
"treatment_split must be a number between 0 and 100 as it represents the percentage of users who will be bucketed into the treatment group (got {})",
v
),
Error::InvalidExposure(v) => write!(
f,
"exposure must be a number between 0 and 100 as it represents the percentage of users who will be bucketed into the experiment (got {})",
v
),
Error::EmptyUniqueId => f.write_str("unique_id must be a non-empty string"),
Error::EmptyExperimentName => f.write_str("experiment_name must be a non-empty string"),
}
}
}
impl std::error::Error for Error {}
pub fn get_bucket(params: &Params<'_>) -> Result<Assignment, Error> {
if !is_percentage(params.treatment_split) {
return Err(Error::InvalidTreatmentSplit(params.treatment_split));
}
if !is_percentage(params.exposure) {
return Err(Error::InvalidExposure(params.exposure));
}
if params.unique_id.is_empty() {
return Err(Error::EmptyUniqueId);
}
if !params.legacy_hashing && params.experiment_name.is_empty() {
return Err(Error::EmptyExperimentName);
}
let bucket = if params.legacy_hashing {
bucket_of(params.unique_id.as_bytes())
} else {
let mut key =
String::with_capacity(params.experiment_name.len() + 1 + params.unique_id.len());
key.push_str(params.experiment_name);
key.push(':');
key.push_str(params.unique_id);
bucket_of(key.as_bytes())
};
let variant = if f64::from(bucket) / 100.0 >= params.exposure {
params.ignore_variant_name
} else if f64::from(bucket % 100) < params.treatment_split {
params.treatment_variant_name
} else {
params.control_variant_name
};
Ok(Assignment {
experiment_name: params.experiment_name.to_owned(),
unique_id: params.unique_id.to_owned(),
resolved_variant: variant.to_owned(),
})
}
fn is_percentage(v: f64) -> bool {
v.is_finite() && (0.0..=100.0).contains(&v)
}
pub(crate) fn bucket_of(key: &[u8]) -> u32 {
let digest = sha256::sha256(key);
let mut top = [0u8; 8];
top.copy_from_slice(&digest[..8]);
bucket_from_top(u64::from_be_bytes(top))
}
pub(crate) fn bucket_from_top(top: u64) -> u32 {
((top as f64) / 18_446_744_073_709_551_616.0 * f64::from(BUCKETS)).floor() as u32
}
#[cfg(test)]
mod vector_tests;