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