growthbook_sdk_rust/feature/
feature_rule_experiment.rs1use 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}