Skip to main content

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