growthbook_sdk_rust/feature/
feature_rule_experiment.rs

1use std::collections::HashMap;
2
3use serde_json::Value;
4
5use crate::dto::GrowthBookFeatureRuleExperiment;
6use crate::extensions::{FindGrowthBookAttribute, JsonHelper};
7use crate::hash::{HashCode, HashCodeVersion};
8use crate::model_private::{ExperimentResult, FeatureResult};
9use crate::model_public::GrowthBookAttribute;
10use crate::namespace::use_case::Namespace;
11use crate::range::model::Range;
12
13impl GrowthBookFeatureRuleExperiment {
14    pub fn get_match_value(
15        &self,
16        feature_name: &str,
17        user_attributes: &Vec<GrowthBookAttribute>,
18        forced_variations: &Option<HashMap<String, i64>>,
19    ) -> Option<FeatureResult> {
20        if let Some(feature_attribute) = &self.hash_attribute {
21            self.check_experiment(&feature_name, user_attributes, forced_variations, feature_attribute)
22        } else {
23            let fallback_attribute = self.get_fallback_attribute();
24            self.check_experiment(&feature_name, user_attributes, forced_variations, &fallback_attribute)
25        }
26    }
27
28    fn check_experiment(
29        &self,
30        feature_name: &&str,
31        user_attributes: &Vec<GrowthBookAttribute>,
32        forced_variations: &Option<HashMap<String, i64>>,
33        feature_attribute: &str,
34    ) -> Option<FeatureResult> {
35        if let Some(user_value) = user_attributes.find_value(feature_attribute) {
36            if let Some((namespace, range)) = &self.namespace_range() {
37                if !Namespace::is_in(&user_value, namespace, range) {
38                    return None;
39                }
40            }
41
42            if let Some(forced_variation) = self.forced_variation(feature_name, user_attributes, forced_variations) {
43                return Some(forced_variation);
44            }
45
46            let user_weight = HashCode::hash_code(&user_value.to_string(), &self.seed(feature_name), HashCodeVersion::from(self.hash_version)).unwrap_or(-1.0);
47            let ranges = self.ranges();
48            let index = choose_variation(user_weight, ranges);
49            if index >= 0 {
50                let usize_index = index as usize;
51                let value = self.variations[usize_index].clone();
52                let (meta_value, pass_through) = self.get_meta_value(usize_index);
53                if !pass_through {
54                    return Some(FeatureResult::experiment(
55                        value.clone(),
56                        self.model_experiment(),
57                        create_experiment_result(
58                            feature_name,
59                            value.clone(),
60                            index,
61                            true,
62                            Some(feature_attribute.to_string()),
63                            Some(user_value.to_value()),
64                            Some(user_weight),
65                            meta_value,
66                        ),
67                    ));
68                }
69            }
70        }
71
72        None
73    }
74
75    fn forced_variation(
76        &self,
77        feature_name: &str,
78        user_attributes: &Vec<GrowthBookAttribute>,
79        forced_variations: &Option<HashMap<String, i64>>,
80    ) -> Option<FeatureResult> {
81        if let Some(forced_variations) = forced_variations {
82            if let Some(found_forced_variation) = forced_variations.get(feature_name) {
83                let hash_attribute = self.hash_attribute.clone().unwrap_or(self.get_fallback_attribute());
84                if let Some(user_value) = user_attributes.find_value(&hash_attribute) {
85                    let forced_variation_index = *found_forced_variation as usize;
86                    let value = self.variations[forced_variation_index].clone();
87                    let (meta_value, pass_through) = self.get_meta_value(forced_variation_index);
88                    if !pass_through {
89                        return Some(FeatureResult::experiment(
90                            value.clone(),
91                            self.model_experiment(),
92                            create_experiment_result(
93                                feature_name,
94                                value.clone(),
95                                *found_forced_variation,
96                                true,
97                                self.hash_attribute.clone(),
98                                Some(user_value.to_value()),
99                                None,
100                                meta_value,
101                            ),
102                        ));
103                    }
104                }
105            }
106        }
107        None
108    }
109
110    fn get_meta_value(
111        &self,
112        usize_index: usize,
113    ) -> (String, bool) {
114        match &self.meta {
115            None => (format!("{usize_index}"), false),
116            Some(it) => {
117                if let Some(meta_value) = it.force_array(vec![]).get(usize_index) {
118                    let pass_through = if let Some(pass_through_value) = meta_value.get("passthrough") {
119                        pass_through_value.force_bool(false)
120                    } else {
121                        false
122                    };
123
124                    if let Some(key) = meta_value.get("key") {
125                        (key.force_string(""), pass_through)
126                    } else {
127                        (format!("{usize_index}"), pass_through)
128                    }
129                } else {
130                    (format!("{usize_index}"), false)
131                }
132            },
133        }
134    }
135
136    fn get_fallback_attribute(&self) -> String {
137        self.fallback_attribute.clone().unwrap_or(String::from("id"))
138    }
139}
140
141#[allow(clippy::too_many_arguments)]
142fn create_experiment_result(
143    feature_name: &str,
144    value: Value,
145    variation_id: i64,
146    hash_used: bool,
147    hash_attribute: Option<String>,
148    hash_value: Option<Value>,
149    bucket: Option<f32>,
150    key: String,
151) -> ExperimentResult {
152    ExperimentResult {
153        feature_id: String::from(feature_name),
154        value,
155        variation_id,
156        in_experiment: true,
157        hash_used,
158        hash_attribute,
159        hash_value,
160        bucket,
161        key,
162        sticky_bucket_used: false,
163    }
164}
165
166fn choose_variation(
167    user_weight: f32,
168    ranges: Vec<Range>,
169) -> i64 {
170    for (index, range) in ranges.iter().enumerate() {
171        if range.in_range(&user_weight) {
172            return index as i64;
173        }
174    }
175    -1
176}
177
178#[cfg(test)]
179mod test {
180    use std::fs;
181
182    use serde::Deserialize;
183    use serde_json::Value;
184
185    use crate::feature::feature_rule_experiment::choose_variation;
186    use crate::range::model::Range;
187
188    #[tokio::test]
189    async fn evaluate_choose_variation() -> Result<(), Box<dyn std::error::Error>> {
190        let cases = Cases::new();
191
192        for value in cases.choose_variation {
193            let eval_choose_variation = EvalChooseVariation::new(value);
194            let index = choose_variation(eval_choose_variation.weight, eval_choose_variation.ranges);
195            if eval_choose_variation.index != index {
196                panic!(
197                    "EvalChooseVariation failed; name='{}' expected_index={} index={index}",
198                    eval_choose_variation.name, eval_choose_variation.index
199                )
200            }
201        }
202
203        Ok(())
204    }
205
206    #[derive(Deserialize, Clone)]
207    #[serde(rename_all = "camelCase")]
208    struct Cases {
209        choose_variation: Vec<Value>,
210    }
211
212    pub struct EvalChooseVariation {
213        name: String,
214        weight: f32,
215        ranges: Vec<Range>,
216        index: i64,
217    }
218
219    impl EvalChooseVariation {
220        fn new(value: Value) -> Self {
221            let array = value.as_array().expect("Failed to convert to array");
222            Self {
223                name: array[0].as_str().expect("Failed to convert to str").to_string(),
224                weight: array[1].as_f64().expect("Failed to convert to f64") as f32,
225                ranges: array[2]
226                    .as_array()
227                    .expect("Failed to convert to array")
228                    .iter()
229                    .map(|it| {
230                        let array = it.as_array().expect("Failed to convert to array [2]");
231                        Range {
232                            start: array[0].as_f64().expect("Failed to convert to f64") as f32,
233                            end: array[1].as_f64().expect("Failed to convert to f64") as f32,
234                        }
235                    })
236                    .collect(),
237                index: array[3].as_i64().expect("Failed to convert to i64"),
238            }
239        }
240    }
241
242    impl Cases {
243        pub fn new() -> Self {
244            let contents = fs::read_to_string("./tests/all_cases.json").expect("Should have been able to read the file");
245
246            serde_json::from_str(&contents).expect("Failed to create cases")
247        }
248    }
249}