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