statsig_rust/evaluation/
evaluator.rs

1use chrono::Utc;
2use lazy_static::lazy_static;
3
4use crate::evaluation::cmab_evaluator::evaluate_cmab;
5use crate::evaluation::comparisons::{
6    compare_arrays, compare_numbers, compare_str_with_regex, compare_strings_in_array,
7    compare_time, compare_versions,
8};
9use crate::evaluation::dynamic_value::DynamicValue;
10use crate::evaluation::evaluation_details::EvaluationDetails;
11use crate::evaluation::evaluation_types::SecondaryExposure;
12use crate::evaluation::evaluator_context::EvaluatorContext;
13use crate::evaluation::evaluator_value::{EvaluatorValue, MemoizedEvaluatorValue};
14use crate::evaluation::get_unit_id::get_unit_id;
15use crate::evaluation::user_agent_parsing::UserAgentParser;
16use crate::interned_string::InternedString;
17use crate::specs_response::spec_types::{Condition, Rule, Spec};
18use crate::{dyn_value, log_w, unwrap_or_return, StatsigErr};
19
20use super::country_lookup::CountryLookup;
21
22const TAG: &str = "Evaluator";
23
24pub struct Evaluator;
25
26lazy_static! {
27    static ref EMPTY_STR: String = String::new();
28    static ref EMPTY_DYNAMIC_VALUE: DynamicValue = DynamicValue::new();
29    static ref DEFAULT_RULE: InternedString = InternedString::from_str_ref("default");
30    static ref DISABLED_RULE: InternedString = InternedString::from_str_ref("disabled");
31    static ref SALT: InternedString = InternedString::from_str_ref("salt");
32}
33
34#[derive(Clone)]
35pub enum SpecType {
36    Gate,
37    DynamicConfig,
38    Experiment,
39    Layer,
40}
41
42#[derive(PartialEq, Eq, Debug)]
43pub enum Recognition {
44    Unrecognized,
45    Recognized,
46}
47
48impl Evaluator {
49    pub fn evaluate_with_details(
50        ctx: &mut EvaluatorContext,
51        spec_name: &str,
52        spec_type: &SpecType,
53    ) -> Result<EvaluationDetails, StatsigErr> {
54        let recognition = Self::evaluate(ctx, spec_name, spec_type)?;
55
56        if recognition == Recognition::Unrecognized {
57            return Ok(EvaluationDetails::unrecognized(ctx.spec_store_data));
58        }
59
60        if let Some(reason) = ctx.result.override_reason {
61            return Ok(EvaluationDetails::recognized_but_overridden(
62                ctx.spec_store_data,
63                reason,
64            ));
65        }
66
67        Ok(EvaluationDetails::recognized(
68            ctx.spec_store_data,
69            &ctx.result,
70        ))
71    }
72
73    pub fn evaluate(
74        ctx: &mut EvaluatorContext,
75        spec_name: &str,
76        spec_type: &SpecType,
77    ) -> Result<Recognition, StatsigErr> {
78        let opt_addressable_spec = match spec_type {
79            SpecType::Gate => ctx.spec_store_data.values.feature_gates.get(spec_name),
80            SpecType::DynamicConfig => ctx.spec_store_data.values.dynamic_configs.get(spec_name),
81            SpecType::Experiment => ctx.spec_store_data.values.dynamic_configs.get(spec_name),
82            SpecType::Layer => ctx.spec_store_data.values.layer_configs.get(spec_name),
83        };
84
85        let opt_spec = opt_addressable_spec.map(|a| a.spec.as_ref());
86
87        if try_apply_override(ctx, spec_name, spec_type, opt_spec) {
88            return Ok(Recognition::Recognized);
89        }
90
91        if try_apply_config_mapping(ctx, spec_name, spec_type, opt_spec) {
92            return Ok(Recognition::Recognized);
93        }
94
95        if evaluate_cmab(ctx, spec_name, spec_type) {
96            return Ok(Recognition::Recognized);
97        }
98
99        let addressable_spec =
100            unwrap_or_return!(opt_addressable_spec, Ok(Recognition::Unrecognized));
101        let spec = unwrap_or_return!(opt_spec, Ok(Recognition::Unrecognized));
102
103        if ctx.result.name.is_none() {
104            ctx.result.name = Some(&addressable_spec.name);
105        }
106
107        if ctx.result.id_type.is_none() {
108            ctx.result.id_type = Some(InternedString::from_str_ref(&spec.id_type));
109        }
110
111        if ctx.result.version.is_none() {
112            if let Some(version) = spec.version {
113                ctx.result.version = Some(version);
114            }
115        }
116
117        if let Some(is_active) = spec.is_active {
118            ctx.result.is_experiment_active = is_active;
119        }
120
121        if let Some(has_shared_params) = spec.has_shared_params {
122            ctx.result.is_in_layer = has_shared_params;
123        }
124
125        if let Some(explicit_params) = &spec.explicit_parameters {
126            ctx.result.explicit_parameters = Some(explicit_params);
127        }
128
129        for rule in &spec.rules {
130            evaluate_rule(ctx, rule)?;
131
132            if ctx.result.unsupported {
133                return Ok(Recognition::Recognized);
134            }
135
136            if !ctx.result.bool_value {
137                continue;
138            }
139
140            if evaluate_config_delegate(ctx, rule)? {
141                ctx.finalize_evaluation(spec, Some(rule));
142                return Ok(Recognition::Recognized);
143            }
144
145            let did_pass = evaluate_pass_percentage(ctx, rule, &spec.salt);
146
147            if did_pass {
148                ctx.result.bool_value = rule.return_value.get_bool() != Some(false);
149                ctx.result.json_value = Some(rule.return_value.clone());
150            } else {
151                ctx.result.bool_value = spec.default_value.get_bool() == Some(true);
152                ctx.result.json_value = Some(spec.default_value.clone());
153            }
154
155            ctx.result.rule_id = Some(&rule.id);
156            ctx.result.group_name = rule.group_name.clone();
157            ctx.result.is_experiment_group = rule.is_experiment_group.unwrap_or(false);
158            ctx.result.is_experiment_active = spec.is_active.unwrap_or(false);
159            ctx.finalize_evaluation(spec, Some(rule));
160            return Ok(Recognition::Recognized);
161        }
162
163        ctx.result.bool_value = spec.default_value.get_bool() == Some(true);
164        ctx.result.json_value = Some(spec.default_value.clone());
165        ctx.result.rule_id = match spec.enabled {
166            true => Some(&DEFAULT_RULE),
167            false => Some(&DISABLED_RULE),
168        };
169        ctx.finalize_evaluation(spec, None);
170
171        Ok(Recognition::Recognized)
172    }
173}
174
175fn try_apply_config_mapping(
176    ctx: &mut EvaluatorContext,
177    spec_name: &str,
178    spec_type: &SpecType,
179    opt_spec: Option<&Spec>,
180) -> bool {
181    let overrides = match &ctx.spec_store_data.values.overrides {
182        Some(overrides) => overrides,
183        None => return false,
184    };
185
186    let override_rules = match &ctx.spec_store_data.values.override_rules {
187        Some(override_rules) => override_rules,
188        None => return false,
189    };
190
191    let mapping_list = match overrides.get(spec_name) {
192        Some(mapping_list) => mapping_list,
193        None => return false,
194    };
195
196    let spec_salt = match opt_spec {
197        Some(spec) => &spec.salt,
198        None => InternedString::empty_ref(),
199    };
200
201    for mapping in mapping_list {
202        for override_rule in &mapping.rules {
203            let start_time = override_rule.start_time.unwrap_or_default();
204
205            if start_time > Utc::now().timestamp_millis() {
206                continue;
207            }
208
209            let rule = match override_rules.get(&override_rule.rule_name) {
210                Some(rule) => rule,
211                None => continue,
212            };
213            match evaluate_rule(ctx, rule) {
214                Ok(_) => {}
215                Err(_) => {
216                    ctx.reset_result();
217                    continue;
218                }
219            }
220
221            if !ctx.result.bool_value || ctx.result.unsupported {
222                ctx.reset_result();
223                continue;
224            }
225            ctx.reset_result();
226            let pass = evaluate_pass_percentage(ctx, rule, spec_salt);
227            if pass {
228                ctx.result.override_config_name = Some(&mapping.new_config_name);
229                match Evaluator::evaluate(ctx, mapping.new_config_name.as_str(), spec_type) {
230                    Ok(Recognition::Recognized) => {
231                        return true;
232                    }
233                    _ => {
234                        ctx.reset_result();
235                        break;
236                    }
237                }
238            }
239        }
240    }
241
242    false
243}
244
245fn try_apply_override(
246    ctx: &mut EvaluatorContext,
247    spec_name: &str,
248    spec_type: &SpecType,
249    opt_spec: Option<&Spec>,
250) -> bool {
251    let adapter = match &ctx.override_adapter {
252        Some(adapter) => adapter,
253        None => return false,
254    };
255
256    match spec_type {
257        SpecType::Gate => adapter.get_gate_override(ctx.user.user_ref, spec_name, &mut ctx.result),
258
259        SpecType::DynamicConfig => {
260            adapter.get_dynamic_config_override(ctx.user.user_ref, spec_name, &mut ctx.result)
261        }
262
263        SpecType::Experiment => {
264            adapter.get_experiment_override(ctx.user.user_ref, spec_name, &mut ctx.result, opt_spec)
265        }
266
267        SpecType::Layer => {
268            adapter.get_layer_override(ctx.user.user_ref, spec_name, &mut ctx.result)
269        }
270    }
271}
272
273fn evaluate_rule<'a>(ctx: &mut EvaluatorContext<'a>, rule: &'a Rule) -> Result<(), StatsigErr> {
274    let mut all_conditions_pass = true;
275    // println!("--- Eval Rule {} ---", rule.id);
276    for condition_hash in &rule.conditions {
277        // println!("Condition Hash {}", condition_hash);
278        let opt_condition = ctx.spec_store_data.values.condition_map.get(condition_hash);
279        let condition = if let Some(c) = opt_condition {
280            c
281        } else {
282            log_w!(TAG, "Unsupported - Condition not found: {}", condition_hash);
283            ctx.result.unsupported = true;
284            return Ok(());
285        };
286
287        evaluate_condition(ctx, condition)?;
288
289        if !ctx.result.bool_value {
290            all_conditions_pass = false;
291        }
292    }
293
294    ctx.result.bool_value = all_conditions_pass;
295
296    Ok(())
297}
298
299fn evaluate_condition<'a>(
300    ctx: &mut EvaluatorContext<'a>,
301    condition: &'a Condition,
302) -> Result<(), StatsigErr> {
303    let temp_value: Option<DynamicValue>;
304    let target_value = condition
305        .target_value
306        .as_ref()
307        .map(|v| v.inner.as_ref())
308        .unwrap_or(EvaluatorValue::empty().inner.as_ref());
309    let condition_type = condition.condition_type.as_str();
310
311    let value: &DynamicValue = match condition_type {
312        "public" => {
313            ctx.result.bool_value = true;
314            return Ok(());
315        }
316        "fail_gate" | "pass_gate" => {
317            evaluate_nested_gate(ctx, target_value, condition_type)?;
318            return Ok(());
319        }
320        "ua_based" => match ctx.user.get_user_value(&condition.field) {
321            Some(value) => Some(value),
322            None => {
323                temp_value = UserAgentParser::get_value_from_user_agent(
324                    ctx.user,
325                    &condition.field,
326                    &mut ctx.result.override_reason,
327                    ctx.should_user_third_party_parser,
328                );
329                temp_value.as_ref()
330            }
331        },
332        "ip_based" => match ctx.user.get_user_value(&condition.field) {
333            Some(value) => Some(value),
334            None => {
335                temp_value = CountryLookup::get_value_from_ip(ctx.user, &condition.field, ctx);
336                temp_value.as_ref()
337            }
338        },
339        "user_field" => ctx.user.get_user_value(&condition.field),
340        "environment_field" => {
341            temp_value = ctx.user.get_value_from_environment(&condition.field);
342            temp_value.as_ref()
343        }
344        "current_time" => {
345            temp_value = Some(DynamicValue::for_timestamp_evaluation(
346                Utc::now().timestamp_millis(),
347            ));
348            temp_value.as_ref()
349        }
350        "user_bucket" => {
351            temp_value = Some(get_hash_for_user_bucket(ctx, condition));
352            temp_value.as_ref()
353        }
354        "target_app" => ctx.app_id,
355        "unit_id" => ctx.user.get_unit_id(&condition.id_type),
356        _ => {
357            log_w!(
358                TAG,
359                "Unsupported - Unknown condition type: {}",
360                condition_type
361            );
362            ctx.result.unsupported = true;
363            return Ok(());
364        }
365    }
366    .unwrap_or(&EMPTY_DYNAMIC_VALUE);
367
368    // println!("Eval Condition {}, {:?}", condition_type, value);
369
370    let operator = match &condition.operator {
371        Some(operator) => operator.as_str(),
372        None => {
373            log_w!(TAG, "Unsupported - Operator is None",);
374            ctx.result.unsupported = true;
375            return Ok(());
376        }
377    };
378
379    ctx.result.bool_value = match operator {
380        // numerical comparisons
381        "gt" | "gte" | "lt" | "lte" => compare_numbers(value, target_value, operator),
382
383        // version comparisons
384        "version_gt" | "version_gte" | "version_lt" | "version_lte" | "version_eq"
385        | "version_neq" => compare_versions(value, target_value, operator),
386
387        // string/array comparisons
388        "any"
389        | "none"
390        | "str_starts_with_any"
391        | "str_ends_with_any"
392        | "str_contains_any"
393        | "str_contains_none" => compare_strings_in_array(value, target_value, operator, true),
394        "any_case_sensitive" | "none_case_sensitive" => {
395            compare_strings_in_array(value, target_value, operator, false)
396        }
397        "str_matches" => compare_str_with_regex(value, target_value),
398
399        // time comparisons
400        "before" | "after" | "on" => compare_time(value, target_value, operator),
401
402        // strict equals
403        "eq" => target_value.is_equal_to_dynamic_value(value),
404        "neq" => !target_value.is_equal_to_dynamic_value(value),
405
406        // id_lists
407        "in_segment_list" | "not_in_segment_list" => {
408            evaluate_id_list(ctx, operator, target_value, value)
409        }
410
411        "array_contains_any"
412        | "array_contains_none"
413        | "array_contains_all"
414        | "not_array_contains_all" => compare_arrays(value, target_value, operator),
415
416        _ => {
417            log_w!(TAG, "Unsupported - Unknown operator: {}", operator);
418            ctx.result.unsupported = true;
419            return Ok(());
420        }
421    };
422
423    Ok(())
424}
425
426fn evaluate_id_list(
427    ctx: &mut EvaluatorContext<'_>,
428    op: &str,
429    target_value: &MemoizedEvaluatorValue,
430    value: &DynamicValue,
431) -> bool {
432    let list_name = unwrap_or_return!(&target_value.string_value, false);
433    let id_lists = &ctx.spec_store_data.id_lists;
434
435    let list = unwrap_or_return!(id_lists.get(list_name.value.as_str()), false);
436
437    let dyn_str = unwrap_or_return!(&value.string_value, false);
438    let hashed = ctx.hashing.sha256(&dyn_str.value);
439    let lookup_id: String = hashed.chars().take(8).collect();
440
441    let is_in_list = list.ids.contains(&lookup_id);
442
443    if op == "not_in_segment_list" {
444        return !is_in_list;
445    }
446
447    is_in_list
448}
449
450fn evaluate_nested_gate<'a>(
451    ctx: &mut EvaluatorContext<'a>,
452    target_value: &'a MemoizedEvaluatorValue,
453    condition_type: &'a str,
454) -> Result<(), StatsigErr> {
455    let gate_name = target_value
456        .string_value
457        .as_ref()
458        .map(|name| &name.value)
459        .unwrap_or(InternedString::empty_ref());
460
461    match ctx.nested_gate_memo.get(gate_name.as_str()) {
462        Some((previous_bool, previous_rule_id)) => {
463            ctx.result.bool_value = *previous_bool;
464            ctx.result.rule_id = *previous_rule_id;
465        }
466        None => {
467            ctx.prep_for_nested_evaluation()?;
468
469            let _ = Evaluator::evaluate(ctx, gate_name.as_str(), &SpecType::Gate)?;
470
471            if ctx.result.unsupported {
472                return Ok(());
473            }
474
475            if !gate_name.as_str().is_empty() {
476                ctx.nested_gate_memo.insert(
477                    gate_name.as_str(),
478                    (ctx.result.bool_value, ctx.result.rule_id),
479                );
480            }
481        }
482    }
483
484    if !&gate_name.starts_with("segment:") {
485        let res = &ctx.result;
486        let expo = SecondaryExposure {
487            gate: gate_name.clone(),
488            gate_value: InternedString::from_bool(res.bool_value),
489            rule_id: res.rule_id.unwrap_or(InternedString::empty_ref()).clone(),
490        };
491
492        if res.sampling_rate.is_none() {
493            ctx.result.has_seen_analytical_gates = Option::from(true);
494        }
495
496        ctx.result.secondary_exposures.push(expo);
497    }
498
499    if condition_type == "fail_gate" {
500        ctx.result.bool_value = !ctx.result.bool_value;
501    }
502    Ok(())
503}
504
505fn evaluate_config_delegate<'a>(
506    ctx: &mut EvaluatorContext<'a>,
507    rule: &'a Rule,
508) -> Result<bool, StatsigErr> {
509    let delegate = unwrap_or_return!(&rule.config_delegate, Ok(false));
510    let delegate_spec = unwrap_or_return!(
511        ctx.spec_store_data.values.dynamic_configs.get(delegate),
512        Ok(false)
513    );
514
515    ctx.result.undelegated_secondary_exposures = Some(ctx.result.secondary_exposures.clone());
516
517    ctx.prep_for_nested_evaluation()?;
518    let recognition = Evaluator::evaluate(ctx, delegate, &SpecType::Experiment)?;
519    if recognition == Recognition::Unrecognized {
520        ctx.result.undelegated_secondary_exposures = None;
521        return Ok(false);
522    }
523
524    ctx.result.explicit_parameters = delegate_spec.spec.explicit_parameters.as_ref();
525    ctx.result.config_delegate = rule.config_delegate.clone();
526
527    Ok(true)
528}
529
530fn evaluate_pass_percentage(
531    ctx: &mut EvaluatorContext,
532    rule: &Rule,
533    spec_salt: &InternedString,
534) -> bool {
535    if rule.pass_percentage == 100f64 {
536        return true;
537    }
538
539    if rule.pass_percentage == 0f64 {
540        return false;
541    }
542
543    let rule_salt = rule.salt.as_deref().unwrap_or(rule.id.as_str());
544    let unit_id = get_unit_id(ctx, &rule.id_type);
545    let input = format!("{spec_salt}.{rule_salt}.{unit_id}");
546    match ctx.hashing.evaluation_hash(&input) {
547        Some(hash) => ((hash % 10000) as f64) < rule.pass_percentage * 100.0,
548        None => false,
549    }
550}
551
552fn get_hash_for_user_bucket(ctx: &mut EvaluatorContext, condition: &Condition) -> DynamicValue {
553    let unit_id = get_unit_id(ctx, &condition.id_type);
554
555    let mut salt = InternedString::empty_ref();
556
557    if let Some(add_values) = &condition.additional_values {
558        if let Some(v) = add_values.get(&SALT) {
559            salt = v;
560        }
561    }
562
563    let input = format!("{salt}.{unit_id}");
564    let hash = ctx.hashing.evaluation_hash(&input).unwrap_or(1);
565    dyn_value!(hash % 1000)
566}