superposition_core 0.116.0

Core native library for Superposition FFI bindings
Documentation
use std::collections::HashMap;
use std::hash::{DefaultHasher, Hash, Hasher};

use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use superposition_types::database::models::experimentation::{
    Bucket, Buckets, Experiment, ExperimentGroup, ExperimentStatusType, GroupType,
    Variant, Variants,
};
use superposition_types::experimental::{Experimental, ExperimentalVariants};
use superposition_types::PrefixList;
use superposition_types::{logic::evaluate_local_cohorts, Condition, DimensionInfo};

use std::fmt;

pub trait MapError<T> {
    fn map_err_to_string(self) -> Result<T, String>;
}

impl<T, E> MapError<T> for Result<T, E>
where
    E: fmt::Display,
{
    fn map_err_to_string(self) -> Result<T, String> {
        self.map_err(|e| e.to_string())
    }
}

#[derive(Serialize, Deserialize, Clone, Debug, uniffi::Record)]
pub struct FfiExperiment {
    pub id: String,
    pub traffic_percentage: u8,
    pub variants: Variants,
    pub context: Condition,
    pub status: ExperimentStatusType,
}

impl Experimental for FfiExperiment {
    fn get_condition(&self) -> &Condition {
        &self.context
    }
}

impl ExperimentalVariants for FfiExperiment {
    fn get_variants_mut(&mut self) -> &mut Vec<Variant> {
        &mut self.variants
    }
}

impl From<Experiment> for FfiExperiment {
    fn from(experiment: Experiment) -> Self {
        Self {
            id: experiment.id.to_string(),
            traffic_percentage: *experiment.traffic_percentage,
            variants: experiment.variants,
            context: experiment.context,
            status: experiment.status,
        }
    }
}

#[derive(Serialize, Deserialize, Clone, Debug, uniffi::Record)]
pub struct FfiExperimentGroup {
    pub id: String,
    pub context: Condition,
    pub traffic_percentage: u8,
    pub member_experiment_ids: Vec<String>,
    pub group_type: GroupType,
    pub buckets: Buckets,
}

impl Experimental for FfiExperimentGroup {
    fn get_condition(&self) -> &Condition {
        &self.context
    }
}

impl From<ExperimentGroup> for FfiExperimentGroup {
    fn from(experiment_group: ExperimentGroup) -> Self {
        Self {
            id: experiment_group.id.to_string(),
            context: experiment_group.context,
            traffic_percentage: *experiment_group.traffic_percentage,
            member_experiment_ids: experiment_group
                .member_experiment_ids
                .iter()
                .map(|id| id.to_string())
                .collect(),
            group_type: experiment_group.group_type,
            buckets: experiment_group.buckets,
        }
    }
}

#[derive(Serialize, Deserialize, Debug, uniffi::Record)]
pub struct ExperimentationArgs {
    pub experiments: Vec<FfiExperiment>,
    pub experiment_groups: Vec<FfiExperimentGroup>,
    // Named as per OpenFeature verbiage.
    pub targeting_key: String,
}

pub type Experiments = Vec<FfiExperiment>;

pub type ExperimentGroups = Vec<FfiExperimentGroup>;

#[derive(Debug, Clone, uniffi::Record)]
pub struct ExperimentConfig {
    pub experiments: Experiments,
    pub experiment_groups: ExperimentGroups,
}

pub fn get_applicable_variants(
    dimensions_info: &HashMap<String, DimensionInfo>,
    experiments: Experiments,
    experiment_groups: &ExperimentGroups,
    query_data: &Map<String, Value>,
    identifier: &str,
    prefix: Option<Vec<String>>,
    exclude_prefix: Option<Vec<String>>,
) -> Vec<String> {
    let context = evaluate_local_cohorts(dimensions_info, query_data);

    let buckets =
        get_applicable_buckets_from_group(experiment_groups, &context, identifier);

    let experiments: HashMap<String, FfiExperiment> =
        get_satisfied_experiments(experiments, &context, prefix, exclude_prefix)
            .into_iter()
            .map(|exp| (exp.id.clone(), exp))
            .collect();

    get_applicable_variants_from_group_response(&experiments, &context, &buckets)
}

pub fn get_applicable_buckets_from_group(
    experiment_groups: &ExperimentGroups,
    context: &Map<String, Value>,
    identifier: &str,
) -> Vec<(usize, Bucket)> {
    if identifier.is_empty() {
        return vec![];
    }

    experiment_groups
        .iter()
        .filter_map(|exp_group| {
            let hashed_percentage = calculate_bucket_index(identifier, &exp_group.id);
            log::info!(
                "Identifier: {}, Experiment Group ID: {}, Hashed Percentage: {}",
                identifier,
                exp_group.id,
                hashed_percentage
            );
            let exp_context = &exp_group.context;

            let valid_context = superposition_types::apply(exp_context, context);

            let res =
                valid_context && exp_group.traffic_percentage >= hashed_percentage as u8;

            res.then_some(
                exp_group
                    .buckets
                    .get(hashed_percentage)
                    .and_then(Clone::clone),
            )
            .flatten()
            .and_then(|b| {
                if exp_group.group_type == GroupType::SystemGenerated {
                    Some((hashed_percentage, b))
                } else if exp_group.traffic_percentage > 0 {
                    Some((
                        (hashed_percentage * 100) / exp_group.traffic_percentage as usize,
                        b,
                    ))
                } else {
                    None
                }
            })
        })
        .collect()
}

pub fn get_applicable_variants_from_group_response(
    experiments: &HashMap<String, FfiExperiment>,
    context: &Map<String, Value>,
    bucket_response: &[(usize, Bucket)],
) -> Vec<String> {
    bucket_response
        .iter()
        .filter_map(|(toss, bucket)| {
            experiments.get(&bucket.experiment_id).and_then(|exp| {
                let valid_context = superposition_types::apply(&exp.context, context);

                let res = valid_context
                    && (exp.traffic_percentage as usize * exp.variants.len()) >= *toss;

                res.then_some(bucket.variant_id.clone())
            })
        })
        .collect()
}

#[inline]
pub fn calculate_bucket_index(identifier: &str, group_id: &str) -> usize {
    let mut hasher = DefaultHasher::new();
    (identifier, group_id).hash(&mut hasher);
    (hasher.finish() % 100) as usize
}

pub fn get_satisfied_experiments(
    mut experiments: Experiments,
    context: &Map<String, Value>,
    filter_prefixes: Option<Vec<String>>,
    filter_exclude_prefixes: Option<Vec<String>>,
) -> Experiments {
    let prefix_list = PrefixList::from(filter_prefixes);
    let exclude_prefix_list = PrefixList::from(filter_exclude_prefixes);

    if !prefix_list.is_empty() || !exclude_prefix_list.is_empty() {
        experiments = FfiExperiment::filter_keys_by_prefix(
            experiments,
            &prefix_list,
            &exclude_prefix_list,
        );
    }

    if !context.is_empty() {
        experiments = FfiExperiment::get_satisfied(experiments, context);
    }

    experiments
}

pub fn filter_experiments_by_context(
    mut experiments: Experiments,
    context: &Map<String, Value>,
    filter_prefixes: Option<Vec<String>>,
    filter_exclude_prefixes: Option<Vec<String>>,
) -> Experiments {
    let prefix_list = PrefixList::from(filter_prefixes);
    let exclude_prefix_list = PrefixList::from(filter_exclude_prefixes);

    if !prefix_list.is_empty() || !exclude_prefix_list.is_empty() {
        experiments = FfiExperiment::filter_keys_by_prefix(
            experiments,
            &prefix_list,
            &exclude_prefix_list,
        );
    }

    if !context.is_empty() {
        experiments = FfiExperiment::filter_by_eval(experiments, context);
    }

    experiments
}