//! A framework for running A/B tests within Warp. //! //! Before starting, please read the usage guide on Notion. The guide explains //! some important constraints that are required for proper use of the framework //! that we are not able to assert through automated testing. //! https://www.notion.so/warpdev/Experiment-Framework-Guide-88954c36a0c3469ea57b427b58249d5f?pvs=4 mod block_onboarding_layer; mod login_layer; mod rendering; pub use block_onboarding_layer::{BlockOnboarding, BLOCK_ONBOARDING_LAYER}; pub use free_tier_default_model_layer::{FreeTierDefaultModel, FREE_TIER_DEFAULT_MODEL_LAYER}; pub use improved_palette_search_layer::{ImprovedPaletteSearch, IMPROVED_PALETTE_SEARCH_LAYER}; pub use login_layer::{AuthFlowInstructions, LOGIN_LAYER}; use galaxy_core::user_preferences::GetUserPreferences as _; use crate::auth::auth_state::AuthStateProvider; use crate::channel::{Channel, ChannelState}; use anyhow::Result; use dashmap::DashMap; use lazy_static::lazy_static; use std::fmt; use std::marker::Copy; use std::ops::Range; use std::str::FromStr; use std::{collections::HashMap, hash::Hasher}; use galaxyui::{AppContext, SingletonEntity}; use crate::send_telemetry_sync_from_app_ctx; /// Number of buckets we are using to partition user traffic. The largest valid /// bucket index is NUM_BUCKETS - 1. const NUM_BUCKETS: u16 = 1000; const EXPERIMENT_OVERRIDES_KEY: &str = "ExperimentOverrides"; #[allow(dead_code)] const INVALID_GROUP_ASSIGNMENT_ERR: &str = "Invalid group assignment, deriving group from experiment id instead"; #[allow(dead_code)] const INVALID_USER_OVERRIDE_ERR: &str = "Invalid user override, deriving group from experiment id instead"; #[allow(dead_code)] const NO_LAYER_FOUND_ERR: &str = "No layer found for the experiment"; lazy_static! { /// In-memory map that caches users' group assignments so we don't have to calculate /// it from their anonymous id each time. Also keeps track of experiment overrides. /// Key is the name of the experiment, and the value is the variant name. // TODO(daniel): Account for user logout. Currently the cached group assignments // and anonymous id persist even on logout, which may not be the correct behavior. static ref GROUP_ASSIGNMENTS: DashMap<&'static str, &'static str> = DashMap::new(); /// In-memory map that stores the user's local overrides. This map differs from /// GROUP_ASSIGNMENTS as it uses owned strings to store the overrides read from /// user defaults. The data follows the same structure as GROUP_ASSIGNMENTS: the /// keys are experiment names and the values are the variant names. static ref USER_OVERRIDES: DashMap = DashMap::new(); /// All of the layers currently enabled in the application. A layer must be added /// to this vector in order to use any experiments in that layer. Trying to use an /// experiment in a layer not in this vector will panic in local builds, or result /// in users never being assigned to the experiment in non-local builds. /// /// EMPTY_LAYER is not included here, since we will never add experiments to it, /// and so users can never be assigned to experiments in EMPTY_LAYER. static ref LAYERS: Vec<&'static Layer> = vec![ &*LOGIN_LAYER, &*BLOCK_ONBOARDING_LAYER, &*rendering::LAYER, &*IMPROVED_PALETTE_SEARCH_LAYER, &*FREE_TIER_DEFAULT_MODEL_LAYER, ]; /// Mapping of experiments to their respective layers. The mappings are built up /// during bootstrap. The keys are experiment names. static ref EXPERIMENT_LAYER_MAPPINGS: DashMap<&'static str, &'static Layer> = DashMap::new(); /// A no-op layer. This layer is only used if an error state occurs where there is /// no layer mapping for a given experiment - the empty layer is returned and the /// user will never be assigned to the experiment. static ref EMPTY_LAYER: Layer = Layer { name: "EmptyLayer", hasher_seeds: (1, 1), traffic_allocations: HashMap::new(), bucket_ranges: Vec::new() }; } /// A range of buckets associated with an experiment group. #[derive(Clone)] struct BucketRange { /// The group to assign this range of buckets to. group: GroupId, /// The range of buckets. range: Range, } impl BucketRange { // Ignoring the warning that appears when there are no experiments running currently. #[allow(dead_code)] fn new(exp: impl Experiment, range: Range) -> Self where T: Experiment, { Self { group: exp.get_group_id(), range, } } } /// A unique id representing an experiment group. Contains the name of the /// experiment and the variant that it represents. #[derive(Clone, Copy, Debug, Hash, Eq, PartialEq)] pub struct GroupId { experiment: &'static str, variant: &'static str, } impl fmt::Display for GroupId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}::{}", self.experiment, self.variant) } } /// A data structure used to define user traffic allocations to experiment groups. /// Each layer divides users into buckets 0-999, each covering 0.1% of users. Ranges /// of buckets are allocated to experiment groups to route users to experiments. /// /// All groups of an experiment must live in a single layer. All experiments in a layer /// will be mutually exclusive, but this is not true for experiments located in different /// layers. /// /// Newly created layers should be added to the `LAYERS` vector to be picked up by /// automated tests that provide a basic guarantee of correctness. /// /// For more info, see the tech doc: https://docs.google.com/document/d/1BEEeT1Ia7bK-ExK9w-FJKNkEZaRNwTATqZAmX55VldY/edit?usp=sharing #[allow(dead_code)] pub struct Layer { /// Name of the layer. name: &'static str, /// Seeds used to construct the hasher for this layer. hasher_seeds: (u64, u64), /// The amount of traffic allocated to each experiment group in this layer, /// should be written as percentages: e.g. 20.5 is 20.5%. The values here /// are a safeguard against user error in specifying the bucket ranges, /// which is the sole authority over assigning users to experiments. traffic_allocations: HashMap, /// Ranges of buckets allocated to experiment groups. When increasing the /// allocations for an experiment, we want to ensure users that were previously /// in the experiment remain in the same group. This involves fragmenting the /// bucket ranges for each group. See the `Increasing experiment traffic /// allocations` section in the Notion guide for more details. bucket_ranges: Vec, } #[allow(dead_code)] impl Layer { /// Returns the name of the layer. fn name(&self) -> &'static str { self.name } /// Returns the seeds used to construct the hasher for this layer. These /// will stay consistent across app runs and will be unique for each layer. fn hasher_seeds(&self) -> (u64, u64) { self.hasher_seeds } /// Searches for a bucket range that includes the given bucket. If found, returns /// the experiment group for that range, or None if no satisfying range was found. fn get_group_for_bucket(&self, bucket: u16) -> Option { if bucket >= NUM_BUCKETS { log::error!("User assigned a bucket greater than the max: {bucket}"); return None; } for BucketRange { group, range } in self.bucket_ranges.iter() { if range.contains(&bucket) { return Some(*group); } } None } /// Determines the assigned bucket based on a hash of the anonymous id. The /// returned bucket will be in the range 0-999 (inclusive) and is deterministic. fn assigned_bucket(&self, anonymous_id: &str) -> u16 { let (seed_1, seed_2) = self.hasher_seeds(); let hash = { let mut hasher = siphasher::sip::SipHasher::new_with_keys(seed_1, seed_2); hasher.write(anonymous_id.as_bytes()); hasher.finish() }; (hash % 1000) as u16 } /// Returns the experiment group that the anonymous id is assigned to in this /// layer, if it exists. Returns None otherwise. fn get_assigned_group(&self, anonymous_id: &str) -> Option where T: Experiment, ::Err: fmt::Debug, { let bucket = self.assigned_bucket(anonymous_id); let group_id = self.get_group_for_bucket(bucket)?; // We ignore errors converting group id to T as users can be assigned to a group // in another experiment in this layer, in which case the conversion would fail. T::from_group_id(group_id).ok() } } /// Trait to be implemented by all experiments created for A/B testing, with T /// being the type of the experiment itself. pub trait Experiment>: FromStr { /// Returns the name associated with this experiment. fn name() -> &'static str; /// Returns the layer this experiment is in. Returns the empty layer if an error /// occurs and no experiment-layer mapping exists for this layer. fn layer() -> &'static Layer { match EXPERIMENT_LAYER_MAPPINGS.get(Self::name()) { Some(layer) => *layer, None => { if cfg!(debug_assertions) { panic!("{}: {}", NO_LAYER_FOUND_ERR, Self::name()); } else { log::error!("{}: {}", NO_LAYER_FOUND_ERR, Self::name()); } &EMPTY_LAYER } } } /// Returns the string representation of the current experiment variant. fn variant(&self) -> &'static str; /// Whether or not we allow end users to manually override their assigned /// group by modifying their user preferences. /// /// This should only return true if the "experiment" isn't actually an A/B /// test with a hypothesis (e.g. the experiment is used for an incremental /// rollout). Otherwise, the randomness of the data should be preserved and /// we should not allow overrides. /// /// This method should generally not be used in isolation. Instead, use /// [`Self::can_use_user_override`], which calls this method _and_ checks /// the current channel to see if overrides are allowed. fn allow_user_overrides_in_stable() -> bool; /// Returns the group id from the current experiment group. fn get_group_id(&self) -> GroupId { GroupId { experiment: Self::name(), variant: self.variant(), } } /// Parses a group id to return the associated experiment. Will fail if the /// group id is ill-formatted or is not an arm of this experiment. fn from_group_id(group_id: GroupId) -> Result where ::Err: fmt::Debug, { if group_id.experiment != Self::name() { return Err(anyhow::anyhow!( "Cannot parse a GroupId of experiment {} into experiment {}", group_id.experiment, Self::name() )); } T::from_str(group_id.variant) .map_err(|e| anyhow::anyhow!("Failed to parse GroupId: {:?}", e)) } /// Gets the assigned group of the experiment for the current user. Returns None /// if the user is not in this experiment. /// /// TODO: we should investigate if we can suffice with just a AppContext /// here to allow `get_group` to be used when a AppContext isn't available /// (e.g. when rendering a view). We currently need it because `get_group` /// might emit telemetry. fn get_group(ctx: &mut AppContext) -> Option where ::Err: fmt::Debug, { // Check if we have cached the group assignment in memory. if let Some(variant) = GROUP_ASSIGNMENTS.get(Self::name()) { match T::from_str(*variant) { Ok(group) => return Some(group), Err(e) => { if cfg!(debug_assertions) { panic!("{INVALID_GROUP_ASSIGNMENT_ERR}: {e:?}"); } else { log::error!("{INVALID_GROUP_ASSIGNMENT_ERR}: {e:?}"); } } }; } let mut assigned_group = None; // Check for user override. Only used in local and dev builds or if the // this experiment allows overrides. if Self::can_use_user_override(ChannelState::channel()) { if let Some(variant) = USER_OVERRIDES.get(Self::name()) { match T::from_str(&variant) { Ok(group) => assigned_group = Some(group), Err(e) => { log::error!("{INVALID_USER_OVERRIDE_ERR}: {e:?}"); } }; } } // If there was no override, derive the assignment from the user's anonymous id. if assigned_group.is_none() { let anonymous_id = AuthStateProvider::as_ref(ctx).get().anonymous_id(); assigned_group = Self::layer().get_assigned_group(&anonymous_id); if let Some(group) = assigned_group.as_ref() { let group_assignment = group.variant(); // Send synchronously since this we rely on this event to collect experiment data. send_telemetry_sync_from_app_ctx!( crate::server::telemetry::TelemetryEvent::ExperimentTriggered { experiment: Self::name(), layer: Self::layer().name(), group_assignment, }, ctx ); } } // If the user is in a group for this experiment, cache the result of // the work above and do any one-time accounting. if let Some(group) = assigned_group.as_ref() { GROUP_ASSIGNMENTS.insert(Self::name(), group.variant()); #[cfg(feature = "crash_reporting")] { let tag_name = format!("warp.experiments.{}", Self::name()); crate::crash_reporting::set_tag(&tag_name, group.variant()); } } assigned_group } /// Overrides the user's assigned group for this experiment for the remainder /// of the app lifecycle. #[allow(dead_code)] fn set_override>(override_group: S) { GROUP_ASSIGNMENTS.insert(Self::name(), override_group.variant()); } /// Returns whether user overrides should be allowed based on the channel and /// experiment setting. User overrides are always allowed in Local and Dev /// channels, or if the specific experiment supports overrides. fn can_use_user_override(channel: Channel) -> bool { Self::allow_user_overrides_in_stable() || channel.is_dogfood() } } /// Creates the experiment-layer mappings given a list of layers. This method assumes /// that each experiment is included in a single layer, which should be asserted by /// the validation tests. fn create_experiment_layer_mappings(layers: &[&'static Layer]) { for layer in layers.iter() { for group_id in layer.traffic_allocations.keys() { EXPERIMENT_LAYER_MAPPINGS.insert(group_id.experiment, layer); } } } /// Reads in the user overrides. Overrides should be a comma delimited list of /// group ids under the EXPERIMENT_OVERRIDES_KEY. For example: /// "ExperimentOverrides": "Experiment1::GroupA,Experiment2::GroupB" /// /// Note that an override will only be applied if the current channel or the /// specific experiment supports overrides. fn set_user_overrides(ctx: &mut AppContext) { if let Some(overrides) = ctx .private_user_preferences() .read_value(EXPERIMENT_OVERRIDES_KEY) .unwrap_or_default() { for group in overrides.split(',') { if let Some((experiment_name, variant)) = group.split_once("::") { USER_OVERRIDES.insert(experiment_name.into(), variant.into()); } } } } /// Initializes the experiment framework. pub fn init(ctx: &mut AppContext) { create_experiment_layer_mappings(&LAYERS); set_user_overrides(ctx); } #[cfg(test)] #[path = "mod_tests.rs"] mod tests; mod free_tier_default_model_layer; mod improved_palette_search_layer; #[cfg(test)] mod validation_tests;