statsig_rust/evaluation/
evaluator.rs

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