diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 5936fd6..45ea861 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -10,5 +10,5 @@ Please fill out the following sections to help us quickly review your pull reque ### Checklist -* [ ] Does your PR title have the correct [title format](https://github.com/amplitude/experiments-ruby-server/blob/main/CONTRIBUTING.md#pr-commit-title-conventions)? -* Does your PR have a breaking change?: +- [ ] Does your PR title have the correct [title format](https://github.com/amplitude/experiment-ruby-server/blob/main/CONTRIBUTING.md#pr-commit-title-conventions)? +- Does your PR have a breaking change?: diff --git a/lib/amplitude-experiment.rb b/lib/amplitude-experiment.rb index 60db186..b53396e 100644 --- a/lib/amplitude-experiment.rb +++ b/lib/amplitude-experiment.rb @@ -27,12 +27,7 @@ require 'experiment/cohort/cohort_sync_config' require 'experiment/deployment/deployment_runner' require 'experiment/util/poller' -require 'experiment/evaluation/evaluation' -require 'experiment/evaluation/flag' -require 'experiment/evaluation/murmur3' -require 'experiment/evaluation/select' -require 'experiment/evaluation/semantic_version' -require 'experiment/evaluation/topological_sort' +require 'experiment/evaluation' # Amplitude Experiment Module module AmplitudeExperiment diff --git a/lib/experiment/evaluation.rb b/lib/experiment/evaluation.rb new file mode 100644 index 0000000..495d68c --- /dev/null +++ b/lib/experiment/evaluation.rb @@ -0,0 +1,6 @@ +require 'experiment/evaluation/evaluation' +require 'experiment/evaluation/flag' +require 'experiment/evaluation/murmur3' +require 'experiment/evaluation/select' +require 'experiment/evaluation/semantic_version' +require 'experiment/evaluation/topological_sort' diff --git a/lib/experiment/evaluation/evaluation.rb b/lib/experiment/evaluation/evaluation.rb index 9aa72c5..a1c01cd 100644 --- a/lib/experiment/evaluation/evaluation.rb +++ b/lib/experiment/evaluation/evaluation.rb @@ -1,311 +1,313 @@ # frozen_string_literal: true -module Evaluation - # Engine for evaluating feature flags based on context - class Engine - def evaluate(context, flags) - results = {} - target = { - 'context' => context, - 'result' => results - } - - flags.each do |flag| - variant = evaluate_flag(target, flag) - results[flag.key] = variant if variant - end +module AmplitudeExperiment + module Evaluation + # Engine for evaluating feature flags based on context + class Engine + def evaluate(context, flags) + results = {} + target = { + 'context' => context, + 'result' => results + } + + flags.each do |flag| + variant = evaluate_flag(target, flag) + results[flag.key] = variant if variant + end - results - end + results + end - private - - def evaluate_flag(target, flag) - result = nil - flag.segments.each do |segment| - result = evaluate_segment(target, flag, segment) - next unless result - - # Merge all metadata into the result - metadata = {} - metadata.merge!(flag.metadata) if flag.metadata - metadata.merge!(segment.metadata) if segment.metadata - metadata.merge!(result.metadata) if result.metadata - result.metadata = metadata - break + private + + def evaluate_flag(target, flag) + result = nil + flag.segments.each do |segment| + result = evaluate_segment(target, flag, segment) + next unless result + + # Merge all metadata into the result + metadata = {} + metadata.merge!(flag.metadata) if flag.metadata + metadata.merge!(segment.metadata) if segment.metadata + metadata.merge!(result.metadata) if result.metadata + result.metadata = metadata + break + end + result end - result - end - def evaluate_segment(target, flag, segment) - if segment.conditions - match = evaluate_conditions(target, segment.conditions) - if match + def evaluate_segment(target, flag, segment) + if segment.conditions + match = evaluate_conditions(target, segment.conditions) + if match + variant_key = bucket(target, segment) + variant_key ? flag.variants[variant_key] : nil + end + else + # Null conditions always match variant_key = bucket(target, segment) variant_key ? flag.variants[variant_key] : nil end - else - # Null conditions always match - variant_key = bucket(target, segment) - variant_key ? flag.variants[variant_key] : nil end - end - def evaluate_conditions(target, conditions) - # Outer list logic is "or" (||) - conditions.any? do |inner_conditions| - match = true - inner_conditions.each do |condition| - match = match_condition(target, condition) - break unless match + def evaluate_conditions(target, conditions) + # Outer list logic is "or" (||) + conditions.any? do |inner_conditions| + match = true + inner_conditions.each do |condition| + match = match_condition(target, condition) + break unless match + end + match end - match end - end - def match_condition(target, condition) - prop_value = Evaluation.select(target, condition.selector) - # Special matching for null properties and set type prop values and operators - if !prop_value - match_null(condition.op, condition.values) - elsif set_operator?(condition.op) - prop_value_string_list = coerce_string_array(prop_value) - return false unless prop_value_string_list - - match_set(prop_value_string_list, condition.op, condition.values) - else - prop_value_string = coerce_string(prop_value) - if prop_value_string - match_string(prop_value_string, condition.op, condition.values) + def match_condition(target, condition) + prop_value = Evaluation.select(target, condition.selector) + # Special matching for null properties and set type prop values and operators + if !prop_value + match_null(condition.op, condition.values) + elsif set_operator?(condition.op) + prop_value_string_list = coerce_string_array(prop_value) + return false unless prop_value_string_list + + match_set(prop_value_string_list, condition.op, condition.values) else - false + prop_value_string = coerce_string(prop_value) + if prop_value_string + match_string(prop_value_string, condition.op, condition.values) + else + false + end end end - end - - def get_hash(key) - Murmur3.hash32x86(key) - end - def bucket(target, segment) - unless segment.bucket - # Null bucket means segment is fully rolled out - return segment.variant + def get_hash(key) + Murmur3.hash32x86(key) end - bucketing_value = coerce_string(Evaluation.select(target, segment.bucket.selector)) - if !bucketing_value || bucketing_value.empty? - # Null or empty bucketing value cannot be bucketed - return segment.variant - end + def bucket(target, segment) + unless segment.bucket + # Null bucket means segment is fully rolled out + return segment.variant + end - key_to_hash = "#{segment.bucket.salt}/#{bucketing_value}" - hash = get_hash(key_to_hash) - allocation_value = hash % 100 - distribution_value = (hash / 100).floor + bucketing_value = coerce_string(Evaluation.select(target, segment.bucket.selector)) + if !bucketing_value || bucketing_value.empty? + # Null or empty bucketing value cannot be bucketed + return segment.variant + end + + key_to_hash = "#{segment.bucket.salt}/#{bucketing_value}" + hash = get_hash(key_to_hash) + allocation_value = hash % 100 + distribution_value = (hash / 100).floor - segment.bucket.allocations.each do |allocation| - allocation_start = allocation.range[0] - allocation_end = allocation.range[1] - next unless allocation_value >= allocation_start && allocation_value < allocation_end + segment.bucket.allocations.each do |allocation| + allocation_start = allocation.range[0] + allocation_end = allocation.range[1] + next unless allocation_value >= allocation_start && allocation_value < allocation_end - allocation.distributions.each do |distribution| - distribution_start = distribution.range[0] - distribution_end = distribution.range[1] - return distribution.variant if distribution_value >= distribution_start && distribution_value < distribution_end + allocation.distributions.each do |distribution| + distribution_start = distribution.range[0] + distribution_end = distribution.range[1] + return distribution.variant if distribution_value >= distribution_start && distribution_value < distribution_end + end end - end - segment.variant - end + segment.variant + end - def match_null(op, filter_values) - contains_none = contains_none?(filter_values) - case op - when Operator::IS, Operator::CONTAINS, Operator::LESS_THAN, - Operator::LESS_THAN_EQUALS, Operator::GREATER_THAN, - Operator::GREATER_THAN_EQUALS, Operator::VERSION_LESS_THAN, - Operator::VERSION_LESS_THAN_EQUALS, Operator::VERSION_GREATER_THAN, - Operator::VERSION_GREATER_THAN_EQUALS, Operator::SET_IS, - Operator::SET_CONTAINS, Operator::SET_CONTAINS_ANY - contains_none - when Operator::IS_NOT, Operator::DOES_NOT_CONTAIN, - Operator::SET_DOES_NOT_CONTAIN, Operator::SET_DOES_NOT_CONTAIN_ANY - !contains_none - else - false + def match_null(op, filter_values) + contains_none = contains_none?(filter_values) + case op + when Operator::IS, Operator::CONTAINS, Operator::LESS_THAN, + Operator::LESS_THAN_EQUALS, Operator::GREATER_THAN, + Operator::GREATER_THAN_EQUALS, Operator::VERSION_LESS_THAN, + Operator::VERSION_LESS_THAN_EQUALS, Operator::VERSION_GREATER_THAN, + Operator::VERSION_GREATER_THAN_EQUALS, Operator::SET_IS, + Operator::SET_CONTAINS, Operator::SET_CONTAINS_ANY + contains_none + when Operator::IS_NOT, Operator::DOES_NOT_CONTAIN, + Operator::SET_DOES_NOT_CONTAIN, Operator::SET_DOES_NOT_CONTAIN_ANY + !contains_none + else + false + end end - end - def match_set(prop_values, op, filter_values) - case op - when Operator::SET_IS - set_equals?(prop_values, filter_values) - when Operator::SET_IS_NOT - !set_equals?(prop_values, filter_values) - when Operator::SET_CONTAINS - matches_set_contains_all?(prop_values, filter_values) - when Operator::SET_DOES_NOT_CONTAIN - !matches_set_contains_all?(prop_values, filter_values) - when Operator::SET_CONTAINS_ANY - matches_set_contains_any?(prop_values, filter_values) - when Operator::SET_DOES_NOT_CONTAIN_ANY - !matches_set_contains_any?(prop_values, filter_values) - else - false + def match_set(prop_values, op, filter_values) + case op + when Operator::SET_IS + set_equals?(prop_values, filter_values) + when Operator::SET_IS_NOT + !set_equals?(prop_values, filter_values) + when Operator::SET_CONTAINS + matches_set_contains_all?(prop_values, filter_values) + when Operator::SET_DOES_NOT_CONTAIN + !matches_set_contains_all?(prop_values, filter_values) + when Operator::SET_CONTAINS_ANY + matches_set_contains_any?(prop_values, filter_values) + when Operator::SET_DOES_NOT_CONTAIN_ANY + !matches_set_contains_any?(prop_values, filter_values) + else + false + end end - end - def match_string(prop_value, op, filter_values) - case op - when Operator::IS - matches_is?(prop_value, filter_values) - when Operator::IS_NOT - !matches_is?(prop_value, filter_values) - when Operator::CONTAINS - matches_contains?(prop_value, filter_values) - when Operator::DOES_NOT_CONTAIN - !matches_contains?(prop_value, filter_values) - when Operator::LESS_THAN, Operator::LESS_THAN_EQUALS, - Operator::GREATER_THAN, Operator::GREATER_THAN_EQUALS - matches_comparable?(prop_value, op, filter_values, - method(:parse_number), - method(:comparator)) - when Operator::VERSION_LESS_THAN, Operator::VERSION_LESS_THAN_EQUALS, - Operator::VERSION_GREATER_THAN, Operator::VERSION_GREATER_THAN_EQUALS - matches_comparable?(prop_value, op, filter_values, - SemanticVersion.method(:parse), - method(:comparator)) - when Operator::REGEX_MATCH - matches_regex?(prop_value, filter_values) - when Operator::REGEX_DOES_NOT_MATCH - !matches_regex?(prop_value, filter_values) - else - false + def match_string(prop_value, op, filter_values) + case op + when Operator::IS + matches_is?(prop_value, filter_values) + when Operator::IS_NOT + !matches_is?(prop_value, filter_values) + when Operator::CONTAINS + matches_contains?(prop_value, filter_values) + when Operator::DOES_NOT_CONTAIN + !matches_contains?(prop_value, filter_values) + when Operator::LESS_THAN, Operator::LESS_THAN_EQUALS, + Operator::GREATER_THAN, Operator::GREATER_THAN_EQUALS + matches_comparable?(prop_value, op, filter_values, + method(:parse_number), + method(:comparator)) + when Operator::VERSION_LESS_THAN, Operator::VERSION_LESS_THAN_EQUALS, + Operator::VERSION_GREATER_THAN, Operator::VERSION_GREATER_THAN_EQUALS + matches_comparable?(prop_value, op, filter_values, + SemanticVersion.method(:parse), + method(:comparator)) + when Operator::REGEX_MATCH + matches_regex?(prop_value, filter_values) + when Operator::REGEX_DOES_NOT_MATCH + !matches_regex?(prop_value, filter_values) + else + false + end end - end - def matches_is?(prop_value, filter_values) - if contains_booleans?(filter_values) - lower = prop_value.downcase - return filter_values.any? { |value| value.downcase == lower } if %w[true false].include?(lower) + def matches_is?(prop_value, filter_values) + if contains_booleans?(filter_values) + lower = prop_value.downcase + return filter_values.any? { |value| value.downcase == lower } if %w[true false].include?(lower) + end + filter_values.any? { |value| prop_value == value } end - filter_values.any? { |value| prop_value == value } - end - def matches_contains?(prop_value, filter_values) - filter_values.any? do |filter_value| - prop_value.downcase.include?(filter_value.downcase) + def matches_contains?(prop_value, filter_values) + filter_values.any? do |filter_value| + prop_value.downcase.include?(filter_value.downcase) + end end - end - def matches_comparable?(prop_value, op, filter_values, type_transformer, type_comparator) - prop_value_transformed = type_transformer.call(prop_value) - filter_values_transformed = filter_values - .map { |filter_value| type_transformer.call(filter_value) } - .compact - - if !prop_value_transformed || filter_values_transformed.empty? - filter_values.any? { |filter_value| comparator(prop_value, op, filter_value) } - else - filter_values_transformed.any? do |filter_value_transformed| - type_comparator.call(prop_value_transformed, op, filter_value_transformed) + def matches_comparable?(prop_value, op, filter_values, type_transformer, type_comparator) + prop_value_transformed = type_transformer.call(prop_value) + filter_values_transformed = filter_values + .map { |filter_value| type_transformer.call(filter_value) } + .compact + + if !prop_value_transformed || filter_values_transformed.empty? + filter_values.any? { |filter_value| comparator(prop_value, op, filter_value) } + else + filter_values_transformed.any? do |filter_value_transformed| + type_comparator.call(prop_value_transformed, op, filter_value_transformed) + end end end - end - def comparator(prop_value, op, filter_value) - case op - when Operator::LESS_THAN, Operator::VERSION_LESS_THAN - prop_value < filter_value - when Operator::LESS_THAN_EQUALS, Operator::VERSION_LESS_THAN_EQUALS - prop_value <= filter_value - when Operator::GREATER_THAN, Operator::VERSION_GREATER_THAN - prop_value > filter_value - when Operator::GREATER_THAN_EQUALS, Operator::VERSION_GREATER_THAN_EQUALS - prop_value >= filter_value - else - false + def comparator(prop_value, op, filter_value) + case op + when Operator::LESS_THAN, Operator::VERSION_LESS_THAN + prop_value < filter_value + when Operator::LESS_THAN_EQUALS, Operator::VERSION_LESS_THAN_EQUALS + prop_value <= filter_value + when Operator::GREATER_THAN, Operator::VERSION_GREATER_THAN + prop_value > filter_value + when Operator::GREATER_THAN_EQUALS, Operator::VERSION_GREATER_THAN_EQUALS + prop_value >= filter_value + else + false + end end - end - def matches_regex?(prop_value, filter_values) - filter_values.any? { |filter_value| !!(Regexp.new(filter_value) =~ prop_value) } - end + def matches_regex?(prop_value, filter_values) + filter_values.any? { |filter_value| !!(Regexp.new(filter_value) =~ prop_value) } + end - def contains_none?(filter_values) - filter_values.any? { |filter_value| filter_value == '(none)' } - end + def contains_none?(filter_values) + filter_values.any? { |filter_value| filter_value == '(none)' } + end - def contains_booleans?(filter_values) - filter_values.any? do |filter_value| - case filter_value.downcase - when 'true', 'false' - true - else - false + def contains_booleans?(filter_values) + filter_values.any? do |filter_value| + case filter_value.downcase + when 'true', 'false' + true + else + false + end end end - end - def parse_number(value) - Float(value) - rescue StandardError - nil - end + def parse_number(value) + Float(value) + rescue StandardError + nil + end - def coerce_string(value) - return nil if value.nil? - return value.to_json if value.is_a?(Hash) + def coerce_string(value) + return nil if value.nil? + return value.to_json if value.is_a?(Hash) - value.to_s - end + value.to_s + end - def coerce_string_array(value) - if value.is_a?(Array) - value.map { |e| coerce_string(e) }.compact - else - string_value = value.to_s - begin - parsed_value = JSON.parse(string_value) - if parsed_value.is_a?(Array) - parsed_value.map { |e| coerce_string(e) }.compact - else + def coerce_string_array(value) + if value.is_a?(Array) + value.map { |e| coerce_string(e) }.compact + else + string_value = value.to_s + begin + parsed_value = JSON.parse(string_value) + if parsed_value.is_a?(Array) + parsed_value.map { |e| coerce_string(e) }.compact + else + s = coerce_string(string_value) + s ? [s] : nil + end + rescue JSON::ParserError s = coerce_string(string_value) s ? [s] : nil end - rescue JSON::ParserError - s = coerce_string(string_value) - s ? [s] : nil end end - end - def set_operator?(op) - case op - when Operator::SET_IS, Operator::SET_IS_NOT, - Operator::SET_CONTAINS, Operator::SET_DOES_NOT_CONTAIN, - Operator::SET_CONTAINS_ANY, Operator::SET_DOES_NOT_CONTAIN_ANY - true - else - false + def set_operator?(op) + case op + when Operator::SET_IS, Operator::SET_IS_NOT, + Operator::SET_CONTAINS, Operator::SET_DOES_NOT_CONTAIN, + Operator::SET_CONTAINS_ANY, Operator::SET_DOES_NOT_CONTAIN_ANY + true + else + false + end end - end - def set_equals?(xa, ya) - xs = Set.new(xa) - ys = Set.new(ya) - xs.size == ys.size && ys.all? { |y| xs.include?(y) } - end + def set_equals?(xa, ya) + xs = Set.new(xa) + ys = Set.new(ya) + xs.size == ys.size && ys.all? { |y| xs.include?(y) } + end - def matches_set_contains_all?(prop_values, filter_values) - return false if prop_values.length < filter_values.length + def matches_set_contains_all?(prop_values, filter_values) + return false if prop_values.length < filter_values.length - filter_values.all? { |filter_value| matches_is?(filter_value, prop_values) } - end + filter_values.all? { |filter_value| matches_is?(filter_value, prop_values) } + end - def matches_set_contains_any?(prop_values, filter_values) - filter_values.any? { |filter_value| matches_is?(filter_value, prop_values) } + def matches_set_contains_any?(prop_values, filter_values) + filter_values.any? { |filter_value| matches_is?(filter_value, prop_values) } + end end end end diff --git a/lib/experiment/evaluation/flag.rb b/lib/experiment/evaluation/flag.rb index 5faa32f..0bff68e 100644 --- a/lib/experiment/evaluation/flag.rb +++ b/lib/experiment/evaluation/flag.rb @@ -2,122 +2,124 @@ require 'json' -module Evaluation - class Distribution - attr_accessor :variant, :range - - def self.from_hash(hash) - new.tap do |dist| - dist.variant = hash['variant'] - dist.range = hash['range'] +module AmplitudeExperiment + module Evaluation + class Distribution + attr_accessor :variant, :range + + def self.from_hash(hash) + new.tap do |dist| + dist.variant = hash['variant'] + dist.range = hash['range'] + end end end - end - class Allocation - attr_accessor :range, :distributions + class Allocation + attr_accessor :range, :distributions - def self.from_hash(hash) - new.tap do |alloc| - alloc.range = hash['range'] - alloc.distributions = hash['distributions']&.map { |d| Distribution.from_hash(d) } + def self.from_hash(hash) + new.tap do |alloc| + alloc.range = hash['range'] + alloc.distributions = hash['distributions']&.map { |d| Distribution.from_hash(d) } + end end end - end - class Condition - attr_accessor :selector, :op, :values + class Condition + attr_accessor :selector, :op, :values - def self.from_hash(hash) - new.tap do |cond| - cond.selector = hash['selector'] - cond.op = hash['op'] - cond.values = hash['values'] + def self.from_hash(hash) + new.tap do |cond| + cond.selector = hash['selector'] + cond.op = hash['op'] + cond.values = hash['values'] + end end end - end - class Bucket - attr_accessor :selector, :salt, :allocations + class Bucket + attr_accessor :selector, :salt, :allocations - def self.from_hash(hash) - new.tap do |bucket| - bucket.selector = hash['selector'] - bucket.salt = hash['salt'] - bucket.allocations = hash['allocations']&.map { |a| Allocation.from_hash(a) } + def self.from_hash(hash) + new.tap do |bucket| + bucket.selector = hash['selector'] + bucket.salt = hash['salt'] + bucket.allocations = hash['allocations']&.map { |a| Allocation.from_hash(a) } + end end end - end - class Segment - attr_accessor :bucket, :conditions, :variant, :metadata + class Segment + attr_accessor :bucket, :conditions, :variant, :metadata - def self.from_hash(hash) - new.tap do |segment| - segment.bucket = hash['bucket'] && Bucket.from_hash(hash['bucket']) - segment.conditions = hash['conditions']&.map { |c| c.map { |inner| Condition.from_hash(inner) } } - segment.variant = hash['variant'] - segment.metadata = hash['metadata'] + def self.from_hash(hash) + new.tap do |segment| + segment.bucket = hash['bucket'] && Bucket.from_hash(hash['bucket']) + segment.conditions = hash['conditions']&.map { |c| c.map { |inner| Condition.from_hash(inner) } } + segment.variant = hash['variant'] + segment.metadata = hash['metadata'] + end end end - end - class Variant - attr_accessor :key, :value, :payload, :metadata + class Variant + attr_accessor :key, :value, :payload, :metadata - def [](key) - instance_variable_get("@#{key}") - end + def [](key) + instance_variable_get("@#{key}") + end - def self.from_hash(hash) - new.tap do |variant| - variant.key = hash['key'] - variant.value = hash['value'] - variant.payload = hash['payload'] - variant.metadata = hash['metadata'] + def self.from_hash(hash) + new.tap do |variant| + variant.key = hash['key'] + variant.value = hash['value'] + variant.payload = hash['payload'] + variant.metadata = hash['metadata'] + end end end - end - class Flag - attr_accessor :key, :variants, :segments, :dependencies, :metadata + class Flag + attr_accessor :key, :variants, :segments, :dependencies, :metadata + + def self.from_hash(hash) + new.tap do |flag| + flag.key = hash['key'] + flag.variants = hash['variants'].transform_values { |v| Variant.from_hash(v) } + flag.segments = hash['segments'].map { |s| Segment.from_hash(s) } + flag.dependencies = hash['dependencies'] + flag.metadata = hash['metadata'] + end + end - def self.from_hash(hash) - new.tap do |flag| - flag.key = hash['key'] - flag.variants = hash['variants'].transform_values { |v| Variant.from_hash(v) } - flag.segments = hash['segments'].map { |s| Segment.from_hash(s) } - flag.dependencies = hash['dependencies'] - flag.metadata = hash['metadata'] + # Used for testing + def ==(other) + key == other.key end end - # Used for testing - def ==(other) - key == other.key + module Operator + IS = 'is' + IS_NOT = 'is not' + CONTAINS = 'contains' + DOES_NOT_CONTAIN = 'does not contain' + LESS_THAN = 'less' + LESS_THAN_EQUALS = 'less or equal' + GREATER_THAN = 'greater' + GREATER_THAN_EQUALS = 'greater or equal' + VERSION_LESS_THAN = 'version less' + VERSION_LESS_THAN_EQUALS = 'version less or equal' + VERSION_GREATER_THAN = 'version greater' + VERSION_GREATER_THAN_EQUALS = 'version greater or equal' + SET_IS = 'set is' + SET_IS_NOT = 'set is not' + SET_CONTAINS = 'set contains' + SET_DOES_NOT_CONTAIN = 'set does not contain' + SET_CONTAINS_ANY = 'set contains any' + SET_DOES_NOT_CONTAIN_ANY = 'set does not contain any' + REGEX_MATCH = 'regex match' + REGEX_DOES_NOT_MATCH = 'regex does not match' end end - - module Operator - IS = 'is' - IS_NOT = 'is not' - CONTAINS = 'contains' - DOES_NOT_CONTAIN = 'does not contain' - LESS_THAN = 'less' - LESS_THAN_EQUALS = 'less or equal' - GREATER_THAN = 'greater' - GREATER_THAN_EQUALS = 'greater or equal' - VERSION_LESS_THAN = 'version less' - VERSION_LESS_THAN_EQUALS = 'version less or equal' - VERSION_GREATER_THAN = 'version greater' - VERSION_GREATER_THAN_EQUALS = 'version greater or equal' - SET_IS = 'set is' - SET_IS_NOT = 'set is not' - SET_CONTAINS = 'set contains' - SET_DOES_NOT_CONTAIN = 'set does not contain' - SET_CONTAINS_ANY = 'set contains any' - SET_DOES_NOT_CONTAIN_ANY = 'set does not contain any' - REGEX_MATCH = 'regex match' - REGEX_DOES_NOT_MATCH = 'regex does not match' - end end diff --git a/lib/experiment/evaluation/murmur3.rb b/lib/experiment/evaluation/murmur3.rb index 0ff5c8e..967157c 100644 --- a/lib/experiment/evaluation/murmur3.rb +++ b/lib/experiment/evaluation/murmur3.rb @@ -1,104 +1,108 @@ # frozen_string_literal: true # Implements 32-bit x86 MurmurHash3 -class Murmur3 - C1_32 = -0x3361d2af - C2_32 = 0x1b873593 - R1_32 = 15 - R2_32 = 13 - M_32 = 5 - N_32 = -0x19ab949c +module AmplitudeExperiment + module Evaluation + class Murmur3 + C1_32 = -0x3361d2af + C2_32 = 0x1b873593 + R1_32 = 15 + R2_32 = 13 + M_32 = 5 + N_32 = -0x19ab949c - class << self - def hash32x86(input, seed = 0) - data = string_to_utf8_bytes(input) - length = data.length - n_blocks = length >> 2 - hash = seed + class << self + def hash32x86(input, seed = 0) + data = string_to_utf8_bytes(input) + length = data.length + n_blocks = length >> 2 + hash = seed - # Process body - n_blocks.times do |i| - index = i << 2 - k = read_int_le(data, index) - hash = mix32(k, hash) - end + # Process body + n_blocks.times do |i| + index = i << 2 + k = read_int_le(data, index) + hash = mix32(k, hash) + end - # Process tail - index = n_blocks << 2 - k1 = 0 + # Process tail + index = n_blocks << 2 + k1 = 0 - case length - index - when 3 - k1 ^= data[index + 2] << 16 - k1 ^= data[index + 1] << 8 - k1 ^= data[index] - k1 = (k1 * C1_32) & 0xffffffff - k1 = rotate_left(k1, R1_32) - k1 = (k1 * C2_32) & 0xffffffff - hash ^= k1 - when 2 - k1 ^= data[index + 1] << 8 - k1 ^= data[index] - k1 = (k1 * C1_32) & 0xffffffff - k1 = rotate_left(k1, R1_32) - k1 = (k1 * C2_32) & 0xffffffff - hash ^= k1 - when 1 - k1 ^= data[index] - k1 = (k1 * C1_32) & 0xffffffff - k1 = rotate_left(k1, R1_32) - k1 = (k1 * C2_32) & 0xffffffff - hash ^= k1 - end + case length - index + when 3 + k1 ^= data[index + 2] << 16 + k1 ^= data[index + 1] << 8 + k1 ^= data[index] + k1 = (k1 * C1_32) & 0xffffffff + k1 = rotate_left(k1, R1_32) + k1 = (k1 * C2_32) & 0xffffffff + hash ^= k1 + when 2 + k1 ^= data[index + 1] << 8 + k1 ^= data[index] + k1 = (k1 * C1_32) & 0xffffffff + k1 = rotate_left(k1, R1_32) + k1 = (k1 * C2_32) & 0xffffffff + hash ^= k1 + when 1 + k1 ^= data[index] + k1 = (k1 * C1_32) & 0xffffffff + k1 = rotate_left(k1, R1_32) + k1 = (k1 * C2_32) & 0xffffffff + hash ^= k1 + end - hash ^= length - fmix32(hash) & 0xffffffff - end + hash ^= length + fmix32(hash) & 0xffffffff + end - private + private - def mix32(k, hash) - k = (k * C1_32) & 0xffffffff - k = rotate_left(k, R1_32) - k = (k * C2_32) & 0xffffffff - hash ^= k - hash = rotate_left(hash, R2_32) - ((hash * M_32) + N_32) & 0xffffffff - end + def mix32(k, hash) + k = (k * C1_32) & 0xffffffff + k = rotate_left(k, R1_32) + k = (k * C2_32) & 0xffffffff + hash ^= k + hash = rotate_left(hash, R2_32) + ((hash * M_32) + N_32) & 0xffffffff + end - def fmix32(hash) - hash ^= hash >> 16 - hash = (hash * -0x7a143595) & 0xffffffff - hash ^= hash >> 13 - hash = (hash * -0x3d4d51cb) & 0xffffffff - hash ^= hash >> 16 - hash - end + def fmix32(hash) + hash ^= hash >> 16 + hash = (hash * -0x7a143595) & 0xffffffff + hash ^= hash >> 13 + hash = (hash * -0x3d4d51cb) & 0xffffffff + hash ^= hash >> 16 + hash + end - def rotate_left(x, n, width = 32) - n = n % width if n > width - mask = (0xffffffff << (width - n)) & 0xffffffff - r = ((x & mask) >> (width - n)) & 0xffffffff - ((x << n) | r) & 0xffffffff - end + def rotate_left(x, n, width = 32) + n %= width if n > width + mask = (0xffffffff << (width - n)) & 0xffffffff + r = ((x & mask) >> (width - n)) & 0xffffffff + ((x << n) | r) & 0xffffffff + end - def read_int_le(data, index = 0) - n = (data[index] << 24) | - (data[index + 1] << 16) | - (data[index + 2] << 8) | - data[index + 3] - reverse_bytes(n) - end + def read_int_le(data, index = 0) + n = (data[index] << 24) | + (data[index + 1] << 16) | + (data[index + 2] << 8) | + data[index + 3] + reverse_bytes(n) + end - def reverse_bytes(n) - ((n & -0x1000000) >> 24) | - ((n & 0x00ff0000) >> 8) | - ((n & 0x0000ff00) << 8) | - ((n & 0x000000ff) << 24) - end + def reverse_bytes(n) + ((n & -0x1000000) >> 24) | + ((n & 0x00ff0000) >> 8) | + ((n & 0x0000ff00) << 8) | + ((n & 0x000000ff) << 24) + end - def string_to_utf8_bytes(str) - str.encode('UTF-8').bytes + def string_to_utf8_bytes(str) + str.encode('UTF-8').bytes + end + end end end end diff --git a/lib/experiment/evaluation/select.rb b/lib/experiment/evaluation/select.rb index 94e327b..79b54bd 100644 --- a/lib/experiment/evaluation/select.rb +++ b/lib/experiment/evaluation/select.rb @@ -1,16 +1,18 @@ # frozen_string_literal: true # Selects a value from a nested object using an array of selector keys -module Evaluation - def self.select(selectable, selector) - return nil if selector.nil? || selector.empty? +module AmplitudeExperiment + module Evaluation + def self.select(selectable, selector) + return nil if selector.nil? || selector.empty? - selector.each do |selector_element| - return nil if selector_element.nil? || selectable.nil? + selector.each do |selector_element| + return nil if selector_element.nil? || selectable.nil? - selectable = selectable[selector_element] - end + selectable = selectable[selector_element] + end - selectable.nil? ? nil : selectable + selectable.nil? ? nil : selectable + end end end diff --git a/lib/experiment/evaluation/semantic_version.rb b/lib/experiment/evaluation/semantic_version.rb index 5dc557f..f830972 100644 --- a/lib/experiment/evaluation/semantic_version.rb +++ b/lib/experiment/evaluation/semantic_version.rb @@ -1,52 +1,56 @@ # frozen_string_literal: true -class SemanticVersion - include Comparable +module AmplitudeExperiment + module Evaluation + class SemanticVersion + include Comparable - attr_reader :major, :minor, :patch, :pre_release + attr_reader :major, :minor, :patch, :pre_release - MAJOR_MINOR_REGEX = '(\d+)\.(\d+)' - PATCH_REGEX = '(\d+)' - PRERELEASE_REGEX = '(-(([-\w]+\.?)*))?' - VERSION_PATTERN = /^#{MAJOR_MINOR_REGEX}(\.#{PATCH_REGEX}#{PRERELEASE_REGEX})?$/.freeze + MAJOR_MINOR_REGEX = '(\d+)\.(\d+)' + PATCH_REGEX = '(\d+)' + PRERELEASE_REGEX = '(-(([-\w]+\.?)*))?' + VERSION_PATTERN = /^#{MAJOR_MINOR_REGEX}(\.#{PATCH_REGEX}#{PRERELEASE_REGEX})?$/.freeze - def initialize(major, minor, patch, pre_release = nil) - @major = major - @minor = minor - @patch = patch - @pre_release = pre_release - end + def initialize(major, minor, patch, pre_release = nil) + @major = major + @minor = minor + @patch = patch + @pre_release = pre_release + end - def self.parse(version) - return nil if version.nil? + def self.parse(version) + return nil if version.nil? - match = VERSION_PATTERN.match(version) - return nil unless match + match = VERSION_PATTERN.match(version) + return nil unless match - major = match[1].to_i - minor = match[2].to_i - patch = match[4]&.to_i || 0 - pre_release = match[5] + major = match[1].to_i + minor = match[2].to_i + patch = match[4]&.to_i || 0 + pre_release = match[5] - new(major, minor, patch, pre_release) - end + new(major, minor, patch, pre_release) + end - def <=>(other) - return nil unless other.is_a?(SemanticVersion) + def <=>(other) + return nil unless other.is_a?(SemanticVersion) - result = major <=> other.major - return result unless result.zero? + result = major <=> other.major + return result unless result.zero? - result = minor <=> other.minor - return result unless result.zero? + result = minor <=> other.minor + return result unless result.zero? - result = patch <=> other.patch - return result unless result.zero? + result = patch <=> other.patch + return result unless result.zero? - return 1 if !pre_release && other.pre_release - return -1 if pre_release && !other.pre_release - return 0 if !pre_release && !other.pre_release + return 1 if !pre_release && other.pre_release + return -1 if pre_release && !other.pre_release + return 0 if !pre_release && !other.pre_release - pre_release <=> other.pre_release + pre_release <=> other.pre_release + end + end end end diff --git a/lib/experiment/evaluation/topological_sort.rb b/lib/experiment/evaluation/topological_sort.rb index 8c4cac1..3451e33 100644 --- a/lib/experiment/evaluation/topological_sort.rb +++ b/lib/experiment/evaluation/topological_sort.rb @@ -1,56 +1,53 @@ # frozen_string_literal: true -class CycleError < StandardError - attr_accessor :path - - def initialize(path) - super("Detected a cycle between flags #{path}") - self.path = path - end -end +require_relative '../error' # Performs topological sorting of feature flags based on their dependencies -class TopologicalSort - # Sort flags topologically based on their dependencies - def self.sort(flags, flag_keys = nil) - available = flags.clone - result = [] - starting_keys = flag_keys.nil? || flag_keys.empty? ? flags.keys : flag_keys - - starting_keys.each do |flag_key| - traversal = parent_traversal(flag_key, available) - result.concat(traversal) if traversal - end - - result - end - - # Perform depth-first traversal of flag dependencies - def self.parent_traversal(flag_key, available, path = []) - flag = available[flag_key] - return nil unless flag - - # No dependencies - return flag and remove from available - if !flag.dependencies || flag.dependencies.empty? - available.delete(flag.key) - return [flag] +module AmplitudeExperiment + module Evaluation + class TopologicalSort + # Sort flags topologically based on their dependencies + def self.sort(flags, flag_keys = nil) + available = flags.clone + result = [] + starting_keys = flag_keys.nil? || flag_keys.empty? ? flags.keys : flag_keys + + starting_keys.each do |flag_key| + traversal = parent_traversal(flag_key, available) + result.concat(traversal) if traversal + end + + result + end + + # Perform depth-first traversal of flag dependencies + def self.parent_traversal(flag_key, available, path = []) + flag = available[flag_key] + return nil unless flag + + # No dependencies - return flag and remove from available + if !flag.dependencies || flag.dependencies.empty? + available.delete(flag.key) + return [flag] + end + + # Check for cycles + path.push(flag.key) + result = [] + + flag.dependencies.each do |parent_key| + raise CycleError, path if path.any? { |p| p == parent_key } + + traversal = parent_traversal(parent_key, available, path) + result.concat(traversal) if traversal + end + + result.push(flag) + path.pop + available.delete(flag.key) + + result + end end - - # Check for cycles - path.push(flag.key) - result = [] - - flag.dependencies.each do |parent_key| - raise CycleError, path if path.any? { |p| p == parent_key } - - traversal = parent_traversal(parent_key, available, path) - result.concat(traversal) if traversal - end - - result.push(flag) - path.pop - available.delete(flag.key) - - result end end diff --git a/lib/experiment/local/client.rb b/lib/experiment/local/client.rb index 2865888..b85042a 100644 --- a/lib/experiment/local/client.rb +++ b/lib/experiment/local/client.rb @@ -1,6 +1,8 @@ require 'uri' require 'logger' + require_relative '../../amplitude' +require_relative '../evaluation' module AmplitudeExperiment FLAG_TYPE_MUTUAL_EXCLUSION_GROUP = 'mutual-exclusion-group'.freeze @@ -63,7 +65,7 @@ def evaluate_v2(user, flag_keys = []) flags = @flag_config_storage.flag_configs return {} if flags.nil? - sorted_flags = TopologicalSort.sort(flags, flag_keys) + sorted_flags = Evaluation::TopologicalSort.sort(flags, flag_keys) required_cohorts_in_storage(sorted_flags) user = enrich_user_with_cohorts(user, flags) if @config.cohort_sync_config context = AmplitudeExperiment.user_to_evaluation_context(user) diff --git a/spec/experiment/evaluation/evaluation_intgration_spec.rb b/spec/experiment/evaluation/evaluation_intgration_spec.rb index 57b4ab8..d4e68f9 100644 --- a/spec/experiment/evaluation/evaluation_intgration_spec.rb +++ b/spec/experiment/evaluation/evaluation_intgration_spec.rb @@ -3,493 +3,495 @@ require 'net/http' require 'json' -describe Evaluation::Engine do - let(:deployment_key) { 'server-NgJxxvg8OGwwBsWVXqyxQbdiflbhvugy' } - let(:engine) { Evaluation::Engine.new } - let(:flags) { get_flags(deployment_key) } - - describe 'basic tests' do - it 'tests off' do - user = user_context('user_id', 'device_id') - result = engine.evaluate(user, flags)['test-off'] - expect(result.key).to eq('off') - end +module AmplitudeExperiment + describe Evaluation::Engine do + let(:deployment_key) { 'server-NgJxxvg8OGwwBsWVXqyxQbdiflbhvugy' } + let(:engine) { Evaluation::Engine.new } + let(:flags) { get_flags(deployment_key) } + + describe 'basic tests' do + it 'tests off' do + user = user_context('user_id', 'device_id') + result = engine.evaluate(user, flags)['test-off'] + expect(result.key).to eq('off') + end - it 'tests on' do - user = user_context('user_id', 'device_id') - result = engine.evaluate(user, flags)['test-on'] - expect(result.key).to eq('on') + it 'tests on' do + user = user_context('user_id', 'device_id') + result = engine.evaluate(user, flags)['test-on'] + expect(result.key).to eq('on') + end end - end - describe 'opinionated segment tests' do - it 'tests individual inclusions match' do - # Match user ID - user = user_context('user_id') - result = engine.evaluate(user, flags)['test-individual-inclusions'] - expect(result.key).to eq('on') - expect(result.metadata['segmentName']).to eq('individual-inclusions') - - # Match device ID - user = user_context(nil, 'device_id') - result = engine.evaluate(user, flags)['test-individual-inclusions'] - expect(result.key).to eq('on') - expect(result.metadata['segmentName']).to eq('individual-inclusions') - - # Doesn't match user ID - user = user_context('not_user_id') - result = engine.evaluate(user, flags)['test-individual-inclusions'] - expect(result.key).to eq('off') - - # Doesn't match device ID - user = user_context(nil, 'not_device_id') - result = engine.evaluate(user, flags)['test-individual-inclusions'] - expect(result.key).to eq('off') - end + describe 'opinionated segment tests' do + it 'tests individual inclusions match' do + # Match user ID + user = user_context('user_id') + result = engine.evaluate(user, flags)['test-individual-inclusions'] + expect(result.key).to eq('on') + expect(result.metadata['segmentName']).to eq('individual-inclusions') + + # Match device ID + user = user_context(nil, 'device_id') + result = engine.evaluate(user, flags)['test-individual-inclusions'] + expect(result.key).to eq('on') + expect(result.metadata['segmentName']).to eq('individual-inclusions') + + # Doesn't match user ID + user = user_context('not_user_id') + result = engine.evaluate(user, flags)['test-individual-inclusions'] + expect(result.key).to eq('off') + + # Doesn't match device ID + user = user_context(nil, 'not_device_id') + result = engine.evaluate(user, flags)['test-individual-inclusions'] + expect(result.key).to eq('off') + end - it 'tests flag dependencies on' do - user = user_context('user_id', 'device_id') - result = engine.evaluate(user, flags)['test-flag-dependencies-on'] - expect(result.key).to eq('on') - end + it 'tests flag dependencies on' do + user = user_context('user_id', 'device_id') + result = engine.evaluate(user, flags)['test-flag-dependencies-on'] + expect(result.key).to eq('on') + end - it 'tests flag dependencies off' do - user = user_context('user_id', 'device_id') - result = engine.evaluate(user, flags)['test-flag-dependencies-off'] - expect(result.key).to eq('off') - expect(result.metadata['segmentName']).to eq('flag-dependencies') - end + it 'tests flag dependencies off' do + user = user_context('user_id', 'device_id') + result = engine.evaluate(user, flags)['test-flag-dependencies-off'] + expect(result.key).to eq('off') + expect(result.metadata['segmentName']).to eq('flag-dependencies') + end - it 'tests sticky bucketing' do - # On - user = user_context('user_id', 'device_id', nil, { - '[Experiment] test-sticky-bucketing' => 'on' - }) - - result = engine.evaluate(user, flags)['test-sticky-bucketing'] - expect(result.key).to eq('on') - expect(result.metadata['segmentName']).to eq('sticky-bucketing') - - # Off - user = user_context('user_id', 'device_id', nil, { - '[Experiment] test-sticky-bucketing' => 'off' - }) - result = engine.evaluate(user, flags)['test-sticky-bucketing'] - expect(result.key).to eq('off') - expect(result.metadata['segmentName']).to eq('All Other Users') - - # Non-variant - user = user_context('user_id', 'device_id', nil, { - '[Experiment] test-sticky-bucketing' => 'not-a-variant' - }) - result = engine.evaluate(user, flags)['test-sticky-bucketing'] - expect(result.key).to eq('off') - expect(result.metadata['segmentName']).to eq('All Other Users') + it 'tests sticky bucketing' do + # On + user = user_context('user_id', 'device_id', nil, { + '[Experiment] test-sticky-bucketing' => 'on' + }) + + result = engine.evaluate(user, flags)['test-sticky-bucketing'] + expect(result.key).to eq('on') + expect(result.metadata['segmentName']).to eq('sticky-bucketing') + + # Off + user = user_context('user_id', 'device_id', nil, { + '[Experiment] test-sticky-bucketing' => 'off' + }) + result = engine.evaluate(user, flags)['test-sticky-bucketing'] + expect(result.key).to eq('off') + expect(result.metadata['segmentName']).to eq('All Other Users') + + # Non-variant + user = user_context('user_id', 'device_id', nil, { + '[Experiment] test-sticky-bucketing' => 'not-a-variant' + }) + result = engine.evaluate(user, flags)['test-sticky-bucketing'] + expect(result.key).to eq('off') + expect(result.metadata['segmentName']).to eq('All Other Users') + end end - end - describe 'experiment and flag segment tests' do - it 'tests experiment' do - user = user_context('user_id', 'device_id') - result = engine.evaluate(user, flags)['test-experiment'] - expect(result.key).to eq('on') - expect(result.metadata['experimentKey']).to eq('exp-1') - end + describe 'experiment and flag segment tests' do + it 'tests experiment' do + user = user_context('user_id', 'device_id') + result = engine.evaluate(user, flags)['test-experiment'] + expect(result.key).to eq('on') + expect(result.metadata['experimentKey']).to eq('exp-1') + end - it 'tests flag' do - user = user_context('user_id', 'device_id') - result = engine.evaluate(user, flags)['test-flag'] - expect(result.key).to eq('on') - expect(result.metadata['experimentKey']).to be_nil + it 'tests flag' do + user = user_context('user_id', 'device_id') + result = engine.evaluate(user, flags)['test-flag'] + expect(result.key).to eq('on') + expect(result.metadata['experimentKey']).to be_nil + end end - end - describe 'conditional logic tests' do - it 'tests multiple conditions and values' do - # All match - user = user_context('user_id', 'device_id', nil, { - 'key-1' => 'value-1', - 'key-2' => 'value-2', - 'key-3' => 'value-3' - }) - result = engine.evaluate(user, flags)['test-multiple-conditions-and-values'] - expect(result.key).to eq('on') - - # Some match - user = user_context('user_id', 'device_id', nil, { - 'key-1' => 'value-1', - 'key-2' => 'value-2' - }) - result = engine.evaluate(user, flags)['test-multiple-conditions-and-values'] - expect(result.key).to eq('off') + describe 'conditional logic tests' do + it 'tests multiple conditions and values' do + # All match + user = user_context('user_id', 'device_id', nil, { + 'key-1' => 'value-1', + 'key-2' => 'value-2', + 'key-3' => 'value-3' + }) + result = engine.evaluate(user, flags)['test-multiple-conditions-and-values'] + expect(result.key).to eq('on') + + # Some match + user = user_context('user_id', 'device_id', nil, { + 'key-1' => 'value-1', + 'key-2' => 'value-2' + }) + result = engine.evaluate(user, flags)['test-multiple-conditions-and-values'] + expect(result.key).to eq('off') + end end - end - describe 'conditional property targeting tests' do - it 'tests amplitude property targeting' do - user = user_context('user_id') - result = engine.evaluate(user, flags)['test-amplitude-property-targeting'] - expect(result.key).to eq('on') - end + describe 'conditional property targeting tests' do + it 'tests amplitude property targeting' do + user = user_context('user_id') + result = engine.evaluate(user, flags)['test-amplitude-property-targeting'] + expect(result.key).to eq('on') + end - it 'tests cohort targeting' do - user = user_context(nil, nil, nil, nil, %w[u0qtvwla 12345678]) - result = engine.evaluate(user, flags)['test-cohort-targeting'] - expect(result.key).to eq('on') + it 'tests cohort targeting' do + user = user_context(nil, nil, nil, nil, %w[u0qtvwla 12345678]) + result = engine.evaluate(user, flags)['test-cohort-targeting'] + expect(result.key).to eq('on') - user = user_context(nil, nil, nil, nil, %w[12345678 87654321]) - result = engine.evaluate(user, flags)['test-cohort-targeting'] - expect(result.key).to eq('off') - end + user = user_context(nil, nil, nil, nil, %w[12345678 87654321]) + result = engine.evaluate(user, flags)['test-cohort-targeting'] + expect(result.key).to eq('off') + end - it 'tests group name targeting' do - user = group_context('org name', 'amplitude') - result = engine.evaluate(user, flags)['test-group-name-targeting'] - expect(result.key).to eq('on') - end + it 'tests group name targeting' do + user = group_context('org name', 'amplitude') + result = engine.evaluate(user, flags)['test-group-name-targeting'] + expect(result.key).to eq('on') + end - it 'tests group property targeting' do - user = group_context('org name', 'amplitude', { 'org plan' => 'enterprise2' }) - result = engine.evaluate(user, flags)['test-group-property-targeting'] - expect(result.key).to eq('on') + it 'tests group property targeting' do + user = group_context('org name', 'amplitude', { 'org plan' => 'enterprise2' }) + result = engine.evaluate(user, flags)['test-group-property-targeting'] + expect(result.key).to eq('on') + end end - end - describe 'bucketing tests' do - it 'tests amplitude id bucketing' do - user = user_context(nil, nil, '1234567890') - result = engine.evaluate(user, flags)['test-amplitude-id-bucketing'] - expect(result.key).to eq('on') - end + describe 'bucketing tests' do + it 'tests amplitude id bucketing' do + user = user_context(nil, nil, '1234567890') + result = engine.evaluate(user, flags)['test-amplitude-id-bucketing'] + expect(result.key).to eq('on') + end - it 'tests user id bucketing' do - user = user_context('user_id') - result = engine.evaluate(user, flags)['test-user-id-bucketing'] - expect(result.key).to eq('on') - end + it 'tests user id bucketing' do + user = user_context('user_id') + result = engine.evaluate(user, flags)['test-user-id-bucketing'] + expect(result.key).to eq('on') + end - it 'tests device id bucketing' do - user = user_context(nil, 'device_id') - result = engine.evaluate(user, flags)['test-device-id-bucketing'] - expect(result.key).to eq('on') - end + it 'tests device id bucketing' do + user = user_context(nil, 'device_id') + result = engine.evaluate(user, flags)['test-device-id-bucketing'] + expect(result.key).to eq('on') + end - it 'tests custom user property bucketing' do - user = user_context(nil, nil, nil, { 'key' => 'value' }) - result = engine.evaluate(user, flags)['test-custom-user-property-bucketing'] - expect(result.key).to eq('on') - end + it 'tests custom user property bucketing' do + user = user_context(nil, nil, nil, { 'key' => 'value' }) + result = engine.evaluate(user, flags)['test-custom-user-property-bucketing'] + expect(result.key).to eq('on') + end - it 'tests group name bucketing' do - user = group_context('org name', 'amplitude') - result = engine.evaluate(user, flags)['test-group-name-bucketing'] - expect(result.key).to eq('on') - end + it 'tests group name bucketing' do + user = group_context('org name', 'amplitude') + result = engine.evaluate(user, flags)['test-group-name-bucketing'] + expect(result.key).to eq('on') + end - it 'tests group property bucketing' do - user = group_context('org name', 'amplitude', { 'org plan' => 'enterprise2' }) - result = engine.evaluate(user, flags)['test-group-name-bucketing'] - expect(result.key).to eq('on') + it 'tests group property bucketing' do + user = group_context('org name', 'amplitude', { 'org plan' => 'enterprise2' }) + result = engine.evaluate(user, flags)['test-group-name-bucketing'] + expect(result.key).to eq('on') + end end - end - describe 'bucketing allocation tests' do - it 'tests 1 percent allocation' do - on_count = 0 - 10_000.times do |i| - user = user_context(nil, (i + 1).to_s) - result = engine.evaluate(user, flags)['test-1-percent-allocation'] - on_count += 1 if result&.key == 'on' + describe 'bucketing allocation tests' do + it 'tests 1 percent allocation' do + on_count = 0 + 10_000.times do |i| + user = user_context(nil, (i + 1).to_s) + result = engine.evaluate(user, flags)['test-1-percent-allocation'] + on_count += 1 if result&.key == 'on' + end + expect(on_count).to eq(107) end - expect(on_count).to eq(107) - end - it 'tests 50 percent allocation' do - on_count = 0 - 10_000.times do |i| - user = user_context(nil, (i + 1).to_s) - result = engine.evaluate(user, flags)['test-50-percent-allocation'] - on_count += 1 if result&.key == 'on' + it 'tests 50 percent allocation' do + on_count = 0 + 10_000.times do |i| + user = user_context(nil, (i + 1).to_s) + result = engine.evaluate(user, flags)['test-50-percent-allocation'] + on_count += 1 if result&.key == 'on' + end + expect(on_count).to eq(5009) end - expect(on_count).to eq(5009) - end - it 'tests 99 percent allocation' do - on_count = 0 - 10_000.times do |i| - user = user_context(nil, (i + 1).to_s) - result = engine.evaluate(user, flags)['test-99-percent-allocation'] - on_count += 1 if result&.key == 'on' + it 'tests 99 percent allocation' do + on_count = 0 + 10_000.times do |i| + user = user_context(nil, (i + 1).to_s) + result = engine.evaluate(user, flags)['test-99-percent-allocation'] + on_count += 1 if result&.key == 'on' + end + expect(on_count).to eq(9900) end - expect(on_count).to eq(9900) end - end - describe 'bucketing distribution tests' do - it 'tests 1 percent distribution' do - control = 0 - treatment = 0 - 10_000.times do |i| - user = user_context(nil, (i + 1).to_s) - result = engine.evaluate(user, flags)['test-1-percent-distribution'] - case result&.key - when 'control' - control += 1 - when 'treatment' - treatment += 1 + describe 'bucketing distribution tests' do + it 'tests 1 percent distribution' do + control = 0 + treatment = 0 + 10_000.times do |i| + user = user_context(nil, (i + 1).to_s) + result = engine.evaluate(user, flags)['test-1-percent-distribution'] + case result&.key + when 'control' + control += 1 + when 'treatment' + treatment += 1 + end end + expect(control).to eq(106) + expect(treatment).to eq(9894) end - expect(control).to eq(106) - expect(treatment).to eq(9894) - end - it 'tests 50 percent distribution' do - control = 0 - treatment = 0 - 10_000.times do |i| - user = user_context(nil, (i + 1).to_s) - result = engine.evaluate(user, flags)['test-50-percent-distribution'] - case result&.key - when 'control' - control += 1 - when 'treatment' - treatment += 1 + it 'tests 50 percent distribution' do + control = 0 + treatment = 0 + 10_000.times do |i| + user = user_context(nil, (i + 1).to_s) + result = engine.evaluate(user, flags)['test-50-percent-distribution'] + case result&.key + when 'control' + control += 1 + when 'treatment' + treatment += 1 + end end + expect(control).to eq(4990) + expect(treatment).to eq(5010) end - expect(control).to eq(4990) - expect(treatment).to eq(5010) - end - it 'tests 99 percent distribution' do - control = 0 - treatment = 0 - 10_000.times do |i| - user = user_context(nil, (i + 1).to_s) - result = engine.evaluate(user, flags)['test-99-percent-distribution'] - case result&.key - when 'control' - control += 1 - when 'treatment' - treatment += 1 + it 'tests 99 percent distribution' do + control = 0 + treatment = 0 + 10_000.times do |i| + user = user_context(nil, (i + 1).to_s) + result = engine.evaluate(user, flags)['test-99-percent-distribution'] + case result&.key + when 'control' + control += 1 + when 'treatment' + treatment += 1 + end end + expect(control).to eq(9909) + expect(treatment).to eq(91) end - expect(control).to eq(9909) - expect(treatment).to eq(91) - end - it 'tests multiple distributions' do - a = 0 - b = 0 - c = 0 - d = 0 - 10_000.times do |i| - user = user_context(nil, (i + 1).to_s) - result = engine.evaluate(user, flags)['test-multiple-distributions'] - case result&.key - when 'a' then a += 1 - when 'b' then b += 1 - when 'c' then c += 1 - when 'd' then d += 1 + it 'tests multiple distributions' do + a = 0 + b = 0 + c = 0 + d = 0 + 10_000.times do |i| + user = user_context(nil, (i + 1).to_s) + result = engine.evaluate(user, flags)['test-multiple-distributions'] + case result&.key + when 'a' then a += 1 + when 'b' then b += 1 + when 'c' then c += 1 + when 'd' then d += 1 + end end + expect(a).to eq(2444) + expect(b).to eq(2634) + expect(c).to eq(2447) + expect(d).to eq(2475) end - expect(a).to eq(2444) - expect(b).to eq(2634) - expect(c).to eq(2447) - expect(d).to eq(2475) end - end - describe 'operator tests' do - it 'tests is' do - user = user_context(nil, nil, nil, { 'key' => 'value' }) - result = engine.evaluate(user, flags)['test-is'] - expect(result.key).to eq('on') - end + describe 'operator tests' do + it 'tests is' do + user = user_context(nil, nil, nil, { 'key' => 'value' }) + result = engine.evaluate(user, flags)['test-is'] + expect(result.key).to eq('on') + end - it 'tests is not' do - user = user_context(nil, nil, nil, { 'key' => 'value' }) - result = engine.evaluate(user, flags)['test-is-not'] - expect(result.key).to eq('on') - end + it 'tests is not' do + user = user_context(nil, nil, nil, { 'key' => 'value' }) + result = engine.evaluate(user, flags)['test-is-not'] + expect(result.key).to eq('on') + end - it 'tests contains' do - user = user_context(nil, nil, nil, { 'key' => 'value' }) - result = engine.evaluate(user, flags)['test-contains'] - expect(result.key).to eq('on') - end + it 'tests contains' do + user = user_context(nil, nil, nil, { 'key' => 'value' }) + result = engine.evaluate(user, flags)['test-contains'] + expect(result.key).to eq('on') + end - it 'tests does not contain' do - user = user_context(nil, nil, nil, { 'key' => 'value' }) - result = engine.evaluate(user, flags)['test-does-not-contain'] - expect(result.key).to eq('on') - end + it 'tests does not contain' do + user = user_context(nil, nil, nil, { 'key' => 'value' }) + result = engine.evaluate(user, flags)['test-does-not-contain'] + expect(result.key).to eq('on') + end - it 'tests less' do - user = user_context(nil, nil, nil, { 'key' => '-1' }) - result = engine.evaluate(user, flags)['test-less'] - expect(result.key).to eq('on') - end + it 'tests less' do + user = user_context(nil, nil, nil, { 'key' => '-1' }) + result = engine.evaluate(user, flags)['test-less'] + expect(result.key).to eq('on') + end - it 'tests less or equal' do - user = user_context(nil, nil, nil, { 'key' => '0' }) - result = engine.evaluate(user, flags.select { |f| f.key == 'test-less-or-equal' })['test-less-or-equal'] - expect(result.key).to eq('on') - end + it 'tests less or equal' do + user = user_context(nil, nil, nil, { 'key' => '0' }) + result = engine.evaluate(user, flags.select { |f| f.key == 'test-less-or-equal' })['test-less-or-equal'] + expect(result.key).to eq('on') + end - it 'tests greater' do - user = user_context(nil, nil, nil, { 'key' => '1' }) - result = engine.evaluate(user, flags)['test-greater'] - expect(result.key).to eq('on') - end + it 'tests greater' do + user = user_context(nil, nil, nil, { 'key' => '1' }) + result = engine.evaluate(user, flags)['test-greater'] + expect(result.key).to eq('on') + end - it 'tests greater or equal' do - user = user_context(nil, nil, nil, { 'key' => '0' }) - result = engine.evaluate(user, flags)['test-greater-or-equal'] - expect(result.key).to eq('on') - end + it 'tests greater or equal' do + user = user_context(nil, nil, nil, { 'key' => '0' }) + result = engine.evaluate(user, flags)['test-greater-or-equal'] + expect(result.key).to eq('on') + end - it 'tests version less' do - user = freeform_user_context({ 'version' => '1.9.0' }) - result = engine.evaluate(user, flags)['test-version-less'] - expect(result.key).to eq('on') - end + it 'tests version less' do + user = freeform_user_context({ 'version' => '1.9.0' }) + result = engine.evaluate(user, flags)['test-version-less'] + expect(result.key).to eq('on') + end - it 'tests version less or equal' do - user = freeform_user_context({ 'version' => '1.10.0' }) - result = engine.evaluate(user, flags)['test-version-less-or-equal'] - expect(result.key).to eq('on') - end + it 'tests version less or equal' do + user = freeform_user_context({ 'version' => '1.10.0' }) + result = engine.evaluate(user, flags)['test-version-less-or-equal'] + expect(result.key).to eq('on') + end - it 'tests version greater' do - user = freeform_user_context({ 'version' => '1.10.0' }) - result = engine.evaluate(user, flags)['test-version-greater'] - expect(result.key).to eq('on') - end + it 'tests version greater' do + user = freeform_user_context({ 'version' => '1.10.0' }) + result = engine.evaluate(user, flags)['test-version-greater'] + expect(result.key).to eq('on') + end - it 'tests version greater or equal' do - user = freeform_user_context({ 'version' => '1.9.0' }) - result = engine.evaluate(user, flags)['test-version-greater-or-equal'] - expect(result.key).to eq('on') - end + it 'tests version greater or equal' do + user = freeform_user_context({ 'version' => '1.9.0' }) + result = engine.evaluate(user, flags)['test-version-greater-or-equal'] + expect(result.key).to eq('on') + end - it 'tests set is' do - user = user_context(nil, nil, nil, { 'key' => %w[1 2 3] }) - result = engine.evaluate(user, flags)['test-set-is'] - expect(result.key).to eq('on') - end + it 'tests set is' do + user = user_context(nil, nil, nil, { 'key' => %w[1 2 3] }) + result = engine.evaluate(user, flags)['test-set-is'] + expect(result.key).to eq('on') + end - it 'tests set is not' do - user = user_context(nil, nil, nil, { 'key' => %w[1 2] }) - result = engine.evaluate(user, flags)['test-set-is-not'] - expect(result.key).to eq('on') - end + it 'tests set is not' do + user = user_context(nil, nil, nil, { 'key' => %w[1 2] }) + result = engine.evaluate(user, flags)['test-set-is-not'] + expect(result.key).to eq('on') + end - it 'tests set contains' do - user = user_context(nil, nil, nil, { 'key' => %w[1 2 3 4] }) - result = engine.evaluate(user, flags)['test-set-contains'] - expect(result.key).to eq('on') - end + it 'tests set contains' do + user = user_context(nil, nil, nil, { 'key' => %w[1 2 3 4] }) + result = engine.evaluate(user, flags)['test-set-contains'] + expect(result.key).to eq('on') + end - it 'tests set does not contain' do - user = user_context(nil, nil, nil, { 'key' => %w[1 2 4] }) - result = engine.evaluate(user, flags)['test-set-does-not-contain'] - expect(result.key).to eq('on') - end + it 'tests set does not contain' do + user = user_context(nil, nil, nil, { 'key' => %w[1 2 4] }) + result = engine.evaluate(user, flags)['test-set-does-not-contain'] + expect(result.key).to eq('on') + end - it 'tests set contains any' do - user = user_context(nil, nil, nil, nil, %w[u0qtvwla 12345678]) - result = engine.evaluate(user, flags)['test-set-contains-any'] - expect(result.key).to eq('on') - end + it 'tests set contains any' do + user = user_context(nil, nil, nil, nil, %w[u0qtvwla 12345678]) + result = engine.evaluate(user, flags)['test-set-contains-any'] + expect(result.key).to eq('on') + end - it 'tests set does not contain any' do - user = user_context(nil, nil, nil, nil, %w[12345678 87654321]) - result = engine.evaluate(user, flags)['test-set-does-not-contain-any'] - expect(result.key).to eq('on') - end + it 'tests set does not contain any' do + user = user_context(nil, nil, nil, nil, %w[12345678 87654321]) + result = engine.evaluate(user, flags)['test-set-does-not-contain-any'] + expect(result.key).to eq('on') + end - it 'tests glob match' do - user = user_context(nil, nil, nil, { 'key' => '/path/1/2/3/end' }) - result = engine.evaluate(user, flags)['test-glob-match'] - expect(result.key).to eq('on') - end + it 'tests glob match' do + user = user_context(nil, nil, nil, { 'key' => '/path/1/2/3/end' }) + result = engine.evaluate(user, flags)['test-glob-match'] + expect(result.key).to eq('on') + end - it 'tests glob does not match' do - user = user_context(nil, nil, nil, { 'key' => '/path/1/2/3' }) - result = engine.evaluate(user, flags)['test-glob-does-not-match'] - expect(result.key).to eq('on') - end + it 'tests glob does not match' do + user = user_context(nil, nil, nil, { 'key' => '/path/1/2/3' }) + result = engine.evaluate(user, flags)['test-glob-does-not-match'] + expect(result.key).to eq('on') + end - it 'tests is with booleans' do - # Test with uppercase TRUE/FALSE - user = user_context(nil, nil, nil, { - 'true' => 'TRUE', - 'false' => 'FALSE' - }) - result = engine.evaluate(user, flags)['test-is-with-booleans'] - expect(result.key).to eq('on') - - # Test with title case True/False - user = user_context(nil, nil, nil, { - 'true' => 'True', - 'false' => 'False' - }) - result = engine.evaluate(user, flags)['test-is-with-booleans'] - expect(result.key).to eq('on') - - # Test with lowercase true/false - user = user_context(nil, nil, nil, { - 'true' => 'true', - 'false' => 'false' - }) - result = engine.evaluate(user, flags)['test-is-with-booleans'] - expect(result.key).to eq('on') + it 'tests is with booleans' do + # Test with uppercase TRUE/FALSE + user = user_context(nil, nil, nil, { + 'true' => 'TRUE', + 'false' => 'FALSE' + }) + result = engine.evaluate(user, flags)['test-is-with-booleans'] + expect(result.key).to eq('on') + + # Test with title case True/False + user = user_context(nil, nil, nil, { + 'true' => 'True', + 'false' => 'False' + }) + result = engine.evaluate(user, flags)['test-is-with-booleans'] + expect(result.key).to eq('on') + + # Test with lowercase true/false + user = user_context(nil, nil, nil, { + 'true' => 'true', + 'false' => 'false' + }) + result = engine.evaluate(user, flags)['test-is-with-booleans'] + expect(result.key).to eq('on') + end end - end - # Helper methods - def user_context(user_id = nil, device_id = nil, amplitude_id = nil, user_properties = nil, cohort_ids = nil) - { - 'user' => { - 'user_id' => user_id, - 'device_id' => device_id, - 'amplitude_id' => amplitude_id, - 'user_properties' => user_properties, - 'cohort_ids' => cohort_ids + # Helper methods + def user_context(user_id = nil, device_id = nil, amplitude_id = nil, user_properties = nil, cohort_ids = nil) + { + 'user' => { + 'user_id' => user_id, + 'device_id' => device_id, + 'amplitude_id' => amplitude_id, + 'user_properties' => user_properties, + 'cohort_ids' => cohort_ids + } } - } - end + end - def freeform_user_context(user) - { - 'user' => user - } - end + def freeform_user_context(user) + { + 'user' => user + } + end - def group_context(group_type, group_name, group_properties = nil) - { - 'groups' => { - group_type => { - 'group_name' => group_name, - 'group_properties' => group_properties + def group_context(group_type, group_name, group_properties = nil) + { + 'groups' => { + group_type => { + 'group_name' => group_name, + 'group_properties' => group_properties + } } } - } - end + end - def get_flags(deployment_key) - server_url = 'https://api.lab.amplitude.com' - uri = URI("#{server_url}/sdk/v2/flags?eval_mode=remote") + def get_flags(deployment_key) + server_url = 'https://api.lab.amplitude.com' + uri = URI("#{server_url}/sdk/v2/flags?eval_mode=remote") - request = Net::HTTP::Get.new(uri) - request['Authorization'] = "Api-Key #{deployment_key}" + request = Net::HTTP::Get.new(uri) + request['Authorization'] = "Api-Key #{deployment_key}" - response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| - http.request(request) - end + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| + http.request(request) + end - raise "Response error #{response.code}" unless response.code == '200' + raise "Response error #{response.code}" unless response.code == '200' - JSON.parse(response.body).map { |flag| Evaluation::Flag.from_hash(flag) } + JSON.parse(response.body).map { |flag| Evaluation::Flag.from_hash(flag) } + end end end diff --git a/spec/experiment/evaluation/murmur3_spec.rb b/spec/experiment/evaluation/murmur3_spec.rb index 6cde098..73a3891 100644 --- a/spec/experiment/evaluation/murmur3_spec.rb +++ b/spec/experiment/evaluation/murmur3_spec.rb @@ -1,29 +1,33 @@ # frozen_string_literal: true -RSpec.describe Murmur3 do - let(:murmur_seed) { 0x7f3a21ea } +module AmplitudeExperiment + module Evaluation + RSpec.describe Murmur3 do + let(:murmur_seed) { 0x7f3a21ea } - describe '.hash32x86' do - it 'handles simple input' do - input = 'brian' - result = described_class.hash32x86(input, murmur_seed) - expect(result).to eq(3_948_467_465) - end + describe '.hash32x86' do + it 'handles simple input' do + input = 'brian' + result = described_class.hash32x86(input, murmur_seed) + expect(result).to eq(3_948_467_465) + end - it 'matches reference output for english words' do - inputs = ENGLISH_WORDS.split("\n") - outputs = MURMUR3_X86_32.split("\n") + it 'matches reference output for english words' do + inputs = ENGLISH_WORDS.split("\n") + outputs = MURMUR3_X86_32.split("\n") - inputs.zip(outputs).each do |input, output| - result = described_class.hash32x86(input, murmur_seed) - expect(result).to eq(output.to_i) - end - end + inputs.zip(outputs).each do |input, output| + result = described_class.hash32x86(input, murmur_seed) + expect(result).to eq(output.to_i) + end + end - it 'handles unicode strings' do - expect(described_class.hash32x86('My hovercraft is full of eels.')).to eq(2_953_494_853) - expect(described_class.hash32x86('My 🚀 is full of 🦎.')).to eq(1_818_098_979) - expect(described_class.hash32x86('吉 星 高 照')).to eq(3_435_142_074) + it 'handles unicode strings' do + expect(described_class.hash32x86('My hovercraft is full of eels.')).to eq(2_953_494_853) + expect(described_class.hash32x86('My 🚀 is full of 🦎.')).to eq(1_818_098_979) + expect(described_class.hash32x86('吉 星 高 照')).to eq(3_435_142_074) + end + end end end end diff --git a/spec/experiment/evaluation/select_spec.rb b/spec/experiment/evaluation/select_spec.rb index cd7b8d9..8c0e04d 100644 --- a/spec/experiment/evaluation/select_spec.rb +++ b/spec/experiment/evaluation/select_spec.rb @@ -1,46 +1,48 @@ # frozen_string_literal: true -RSpec.describe Evaluation do - let(:primitive_object) do - { - 'null' => nil, - 'string' => 'value', - 'number' => 13, - 'boolean' => true - } - end - - let(:nested_object) do - primitive_object.merge('object' => primitive_object) - end - - context '.select' do - it 'handles non-existent paths' do - expect(described_class.select(nested_object, %w[does not exist])).to be_nil - end - - it 'handles nil values' do - expect(described_class.select(nested_object, ['null'])).to be_nil - end - - it 'selects primitive values' do - expect(described_class.select(nested_object, ['string'])).to eq('value') - expect(described_class.select(nested_object, ['number'])).to eq(13) - expect(described_class.select(nested_object, ['boolean'])).to eq(true) - end - - it 'selects object values' do - expect(described_class.select(nested_object, ['object'])).to eq(primitive_object) +module AmplitudeExperiment + RSpec.describe Evaluation do + let(:primitive_object) do + { + 'null' => nil, + 'string' => 'value', + 'number' => 13, + 'boolean' => true + } end - it 'selects nested values' do - expect(described_class.select(nested_object, %w[object string])).to eq('value') - expect(described_class.select(nested_object, %w[object number])).to eq(13) - expect(described_class.select(nested_object, %w[object boolean])).to eq(true) + let(:nested_object) do + primitive_object.merge('object' => primitive_object) end - it 'handles non-existent nested paths' do - expect(described_class.select(nested_object, %w[object does not exist])).to be_nil + context '.select' do + it 'handles non-existent paths' do + expect(described_class.select(nested_object, %w[does not exist])).to be_nil + end + + it 'handles nil values' do + expect(described_class.select(nested_object, ['null'])).to be_nil + end + + it 'selects primitive values' do + expect(described_class.select(nested_object, ['string'])).to eq('value') + expect(described_class.select(nested_object, ['number'])).to eq(13) + expect(described_class.select(nested_object, ['boolean'])).to eq(true) + end + + it 'selects object values' do + expect(described_class.select(nested_object, ['object'])).to eq(primitive_object) + end + + it 'selects nested values' do + expect(described_class.select(nested_object, %w[object string])).to eq('value') + expect(described_class.select(nested_object, %w[object number])).to eq(13) + expect(described_class.select(nested_object, %w[object boolean])).to eq(true) + end + + it 'handles non-existent nested paths' do + expect(described_class.select(nested_object, %w[object does not exist])).to be_nil + end end end end diff --git a/spec/experiment/evaluation/semantic_version_spec.rb b/spec/experiment/evaluation/semantic_version_spec.rb index 91af52f..8f060d5 100644 --- a/spec/experiment/evaluation/semantic_version_spec.rb +++ b/spec/experiment/evaluation/semantic_version_spec.rb @@ -1,132 +1,136 @@ -describe SemanticVersion do - def assert_invalid_version(version) - expect(SemanticVersion.parse(version)).to be_nil - end - - def assert_valid_version(version) - expect(SemanticVersion.parse(version)).not_to be_nil - end - - def assert_version_comparison(v1, op, v2) - sv1 = SemanticVersion.parse(v1) - sv2 = SemanticVersion.parse(v2) - expect(sv1).not_to be_nil - expect(sv2).not_to be_nil - return if sv1.nil? || sv2.nil? - - case op - when 'is' - expect(sv1 <=> sv2).to eq(0) - when 'is not' - expect(sv1 <=> sv2).not_to eq(0) - when 'version less' - expect(sv1 <=> sv2).to be < 0 - when 'version greater' - expect(sv1 <=> sv2).to be > 0 - end - end - - describe 'invalid versions' do - it 'rejects invalid version formats' do - # just major - assert_invalid_version('10') - - # trailing dots - assert_invalid_version('10.') - assert_invalid_version('10..') - assert_invalid_version('10.2.') - assert_invalid_version('10.2.33.') - - # dots in the middle - assert_invalid_version('10..2.33') - assert_invalid_version('102...33') - - # invalid characters - assert_invalid_version('a.2.3') - assert_invalid_version('23!') - assert_invalid_version('23.#5') - assert_invalid_version('') - assert_invalid_version(nil) - - # more numbers - assert_invalid_version('2.3.4.567') - assert_invalid_version('2.3.4.5.6.7') - - # prerelease if provided should always have major, minor, patch - assert_invalid_version('10.2.alpha') - assert_invalid_version('10.alpha') - assert_invalid_version('alpha-1.2.3') - - # prerelease should be separated by a hyphen after patch - assert_invalid_version('10.2.3alpha') - assert_invalid_version('10.2.3alpha-1.2.3') - - # negative numbers - assert_invalid_version('-10.1') - assert_invalid_version('10.-1') - end - end - - describe 'valid versions' do - it 'accepts valid version formats' do - assert_valid_version('100.2') - assert_valid_version('0.102.39') - assert_valid_version('0.0.0') - - # versions with leading 0s would be converted to int - assert_valid_version('01.02') - assert_valid_version('001.001100.000900') - - # prerelease tags - assert_valid_version('10.20.30-alpha') - assert_valid_version('10.20.30-1.x.y') - assert_valid_version('10.20.30-aslkjd') - assert_valid_version('10.20.30-b894') - assert_valid_version('10.20.30-b8c9') - end - end - - describe 'version comparison' do - it 'handles equality comparisons' do - assert_version_comparison('66.12.23', 'is', '66.12.23') - # patch if not specified equals 0 - assert_version_comparison('5.6', 'is', '5.6.0') - # leading 0s are not stored when parsed - assert_version_comparison('06.007.0008', 'is', '6.7.8') - # with pre-release - assert_version_comparison('1.23.4-b-1.x.y', 'is', '1.23.4-b-1.x.y') - end - - it 'handles inequality comparisons' do - assert_version_comparison('1.23.4-alpha-1.2', 'is not', '1.23.4-alpha-1') - # trailing 0s aren't stripped - assert_version_comparison('1.2.300', 'is not', '1.2.3') - assert_version_comparison('1.20.3', 'is not', '1.2.3') - end - - it 'handles less than comparisons' do - # patch of .1 makes it greater - assert_version_comparison('50.2', 'version less', '50.2.1') - # minor 9 < minor 20 - assert_version_comparison('20.9', 'version less', '20.20') - # same version with pre-release should be lesser - assert_version_comparison('20.9.4-alpha1', 'version less', '20.9.4') - # compare prerelease as strings - assert_version_comparison('20.9.4-a-1.2.3', 'version less', '20.9.4-a-1.3') - # since prerelease is compared as strings a1.23 < a1.5 because 2 < 5 - assert_version_comparison('20.9.4-a1.23', 'version less', '20.9.4-a1.5') - end - - it 'handles greater than comparisons' do - assert_version_comparison('12.30.2', 'version greater', '12.4.1') - # 100 > 1 - assert_version_comparison('7.100', 'version greater', '7.1') - # 10 > 9 - assert_version_comparison('7.10', 'version greater', '7.9') - # converts to 7.10.20 > 7.9.1 - assert_version_comparison('07.010.0020', 'version greater', '7.009.1') - # patch comparison comes first - assert_version_comparison('20.5.6-b1.2.x', 'version greater', '20.5.5') +module AmplitudeExperiment + module Evaluation + describe SemanticVersion do + def assert_invalid_version(version) + expect(SemanticVersion.parse(version)).to be_nil + end + + def assert_valid_version(version) + expect(SemanticVersion.parse(version)).not_to be_nil + end + + def assert_version_comparison(v1, op, v2) + sv1 = SemanticVersion.parse(v1) + sv2 = SemanticVersion.parse(v2) + expect(sv1).not_to be_nil + expect(sv2).not_to be_nil + return if sv1.nil? || sv2.nil? + + case op + when 'is' + expect(sv1 <=> sv2).to eq(0) + when 'is not' + expect(sv1 <=> sv2).not_to eq(0) + when 'version less' + expect(sv1 <=> sv2).to be < 0 + when 'version greater' + expect(sv1 <=> sv2).to be > 0 + end + end + + describe 'invalid versions' do + it 'rejects invalid version formats' do + # just major + assert_invalid_version('10') + + # trailing dots + assert_invalid_version('10.') + assert_invalid_version('10..') + assert_invalid_version('10.2.') + assert_invalid_version('10.2.33.') + + # dots in the middle + assert_invalid_version('10..2.33') + assert_invalid_version('102...33') + + # invalid characters + assert_invalid_version('a.2.3') + assert_invalid_version('23!') + assert_invalid_version('23.#5') + assert_invalid_version('') + assert_invalid_version(nil) + + # more numbers + assert_invalid_version('2.3.4.567') + assert_invalid_version('2.3.4.5.6.7') + + # prerelease if provided should always have major, minor, patch + assert_invalid_version('10.2.alpha') + assert_invalid_version('10.alpha') + assert_invalid_version('alpha-1.2.3') + + # prerelease should be separated by a hyphen after patch + assert_invalid_version('10.2.3alpha') + assert_invalid_version('10.2.3alpha-1.2.3') + + # negative numbers + assert_invalid_version('-10.1') + assert_invalid_version('10.-1') + end + end + + describe 'valid versions' do + it 'accepts valid version formats' do + assert_valid_version('100.2') + assert_valid_version('0.102.39') + assert_valid_version('0.0.0') + + # versions with leading 0s would be converted to int + assert_valid_version('01.02') + assert_valid_version('001.001100.000900') + + # prerelease tags + assert_valid_version('10.20.30-alpha') + assert_valid_version('10.20.30-1.x.y') + assert_valid_version('10.20.30-aslkjd') + assert_valid_version('10.20.30-b894') + assert_valid_version('10.20.30-b8c9') + end + end + + describe 'version comparison' do + it 'handles equality comparisons' do + assert_version_comparison('66.12.23', 'is', '66.12.23') + # patch if not specified equals 0 + assert_version_comparison('5.6', 'is', '5.6.0') + # leading 0s are not stored when parsed + assert_version_comparison('06.007.0008', 'is', '6.7.8') + # with pre-release + assert_version_comparison('1.23.4-b-1.x.y', 'is', '1.23.4-b-1.x.y') + end + + it 'handles inequality comparisons' do + assert_version_comparison('1.23.4-alpha-1.2', 'is not', '1.23.4-alpha-1') + # trailing 0s aren't stripped + assert_version_comparison('1.2.300', 'is not', '1.2.3') + assert_version_comparison('1.20.3', 'is not', '1.2.3') + end + + it 'handles less than comparisons' do + # patch of .1 makes it greater + assert_version_comparison('50.2', 'version less', '50.2.1') + # minor 9 < minor 20 + assert_version_comparison('20.9', 'version less', '20.20') + # same version with pre-release should be lesser + assert_version_comparison('20.9.4-alpha1', 'version less', '20.9.4') + # compare prerelease as strings + assert_version_comparison('20.9.4-a-1.2.3', 'version less', '20.9.4-a-1.3') + # since prerelease is compared as strings a1.23 < a1.5 because 2 < 5 + assert_version_comparison('20.9.4-a1.23', 'version less', '20.9.4-a1.5') + end + + it 'handles greater than comparisons' do + assert_version_comparison('12.30.2', 'version greater', '12.4.1') + # 100 > 1 + assert_version_comparison('7.100', 'version greater', '7.1') + # 10 > 9 + assert_version_comparison('7.10', 'version greater', '7.9') + # converts to 7.10.20 > 7.9.1 + assert_version_comparison('07.010.0020', 'version greater', '7.009.1') + # patch comparison comes first + assert_version_comparison('20.5.6-b1.2.x', 'version greater', '20.5.5') + end + end end end end diff --git a/spec/experiment/evaluation/topological_sort_spec.rb b/spec/experiment/evaluation/topological_sort_spec.rb index 9cea294..221f38a 100644 --- a/spec/experiment/evaluation/topological_sort_spec.rb +++ b/spec/experiment/evaluation/topological_sort_spec.rb @@ -1,96 +1,100 @@ # frozen_string_literal: true -RSpec.describe TopologicalSort do - def create_flag(key, dependencies = nil) - Evaluation::Flag.from_hash({ - 'key' => key.to_s, - 'variants' => {}, - 'segments' => [], - 'dependencies' => dependencies&.map(&:to_s) - }) - end +module AmplitudeExperiment + module Evaluation + RSpec.describe TopologicalSort do + def create_flag(key, dependencies = nil) + Flag.from_hash({ + 'key' => key.to_s, + 'variants' => {}, + 'segments' => [], + 'dependencies' => dependencies&.map(&:to_s) + }) + end - describe '.sort' do - it 'handles empty flag list' do - expect(TopologicalSort.sort({})).to eq([]) - expect(TopologicalSort.sort({}, ['1'])).to eq([]) - end + describe '.sort' do + it 'handles empty flag list' do + expect(TopologicalSort.sort({})).to eq([]) + expect(TopologicalSort.sort({}, ['1'])).to eq([]) + end - it 'handles single flag without dependencies' do - flags = { '1' => create_flag(1) } - expect(TopologicalSort.sort(flags)).to eq([create_flag(1)]) - expect(TopologicalSort.sort(flags, ['1'])).to eq([create_flag(1)]) - expect(TopologicalSort.sort(flags, ['999'])).to eq([]) - end + it 'handles single flag without dependencies' do + flags = { '1' => create_flag(1) } + expect(TopologicalSort.sort(flags)).to eq([create_flag(1)]) + expect(TopologicalSort.sort(flags, ['1'])).to eq([create_flag(1)]) + expect(TopologicalSort.sort(flags, ['999'])).to eq([]) + end - it 'handles single flag with dependencies' do - flags = { '1' => create_flag(1, [2]) } - expect(TopologicalSort.sort(flags)).to eq([create_flag(1, [2])]) - expect(TopologicalSort.sort(flags, ['1'])).to eq([create_flag(1, [2])]) - expect(TopologicalSort.sort(flags, ['999'])).to eq([]) - end + it 'handles single flag with dependencies' do + flags = { '1' => create_flag(1, [2]) } + expect(TopologicalSort.sort(flags)).to eq([create_flag(1, [2])]) + expect(TopologicalSort.sort(flags, ['1'])).to eq([create_flag(1, [2])]) + expect(TopologicalSort.sort(flags, ['999'])).to eq([]) + end - it 'handles multiple flags without dependencies' do - flags = { - '1' => create_flag(1), - '2' => create_flag(2) - } - expect(TopologicalSort.sort(flags)).to eq([create_flag(1), create_flag(2)]) - expect(TopologicalSort.sort(flags, %w[1 2])).to eq([create_flag(1), create_flag(2)]) - expect(TopologicalSort.sort(flags, %w[99 999])).to eq([]) - end + it 'handles multiple flags without dependencies' do + flags = { + '1' => create_flag(1), + '2' => create_flag(2) + } + expect(TopologicalSort.sort(flags)).to eq([create_flag(1), create_flag(2)]) + expect(TopologicalSort.sort(flags, %w[1 2])).to eq([create_flag(1), create_flag(2)]) + expect(TopologicalSort.sort(flags, %w[99 999])).to eq([]) + end - it 'handles multiple flags with dependencies' do - flags = { - '1' => create_flag(1, [2]), - '2' => create_flag(2, [3]), - '3' => create_flag(3) - } - expected = [create_flag(3), create_flag(2, [3]), create_flag(1, [2])] - expect(TopologicalSort.sort(flags)).to eq(expected) - expect(TopologicalSort.sort(flags, %w[1 2])).to eq(expected) - expect(TopologicalSort.sort(flags, %w[99 999])).to eq([]) - end + it 'handles multiple flags with dependencies' do + flags = { + '1' => create_flag(1, [2]), + '2' => create_flag(2, [3]), + '3' => create_flag(3) + } + expected = [create_flag(3), create_flag(2, [3]), create_flag(1, [2])] + expect(TopologicalSort.sort(flags)).to eq(expected) + expect(TopologicalSort.sort(flags, %w[1 2])).to eq(expected) + expect(TopologicalSort.sort(flags, %w[99 999])).to eq([]) + end - it 'detects single flag cycle' do - flags = { '1' => create_flag(1, [1]) } - expect { TopologicalSort.sort(flags) }.to raise_error(CycleError) { |e| expect(e.path).to eq ['1'] } - expect { TopologicalSort.sort(flags, ['1']) }.to raise_error(CycleError) { |e| expect(e.path).to eq ['1'] } - expect { TopologicalSort.sort(flags, ['999']) }.not_to raise_error - end + it 'detects single flag cycle' do + flags = { '1' => create_flag(1, [1]) } + expect { TopologicalSort.sort(flags) }.to raise_error(CycleError) { |e| expect(e.path).to eq ['1'] } + expect { TopologicalSort.sort(flags, ['1']) }.to raise_error(CycleError) { |e| expect(e.path).to eq ['1'] } + expect { TopologicalSort.sort(flags, ['999']) }.not_to raise_error + end - it 'detects cycles between two flags' do - flags = { - '1' => create_flag(1, [2]), - '2' => create_flag(2, [1]) - } - expect { TopologicalSort.sort(flags) }.to raise_error(CycleError) { |e| expect(e.path).to eq %w[1 2] } - expect { TopologicalSort.sort(flags, ['2']) }.to raise_error(CycleError) { |e| expect(e.path).to eq %w[2 1] } - expect { TopologicalSort.sort(flags, ['999']) }.not_to raise_error - end + it 'detects cycles between two flags' do + flags = { + '1' => create_flag(1, [2]), + '2' => create_flag(2, [1]) + } + expect { TopologicalSort.sort(flags) }.to raise_error(CycleError) { |e| expect(e.path).to eq %w[1 2] } + expect { TopologicalSort.sort(flags, ['2']) }.to raise_error(CycleError) { |e| expect(e.path).to eq %w[2 1] } + expect { TopologicalSort.sort(flags, ['999']) }.not_to raise_error + end - it 'handles complex dependencies without cycles' do - flags = { - '8' => create_flag(8), - '7' => create_flag(7, [8]), - '4' => create_flag(4, [8, 7]), - '6' => create_flag(6, [7, 4]), - '3' => create_flag(3, [6]), - '1' => create_flag(1, [3]), - '2' => create_flag(2, [1]) - } + it 'handles complex dependencies without cycles' do + flags = { + '8' => create_flag(8), + '7' => create_flag(7, [8]), + '4' => create_flag(4, [8, 7]), + '6' => create_flag(6, [7, 4]), + '3' => create_flag(3, [6]), + '1' => create_flag(1, [3]), + '2' => create_flag(2, [1]) + } - expected = [ - create_flag(8), - create_flag(7, [8]), - create_flag(4, [8, 7]), - create_flag(6, [7, 4]), - create_flag(3, [6]), - create_flag(1, [3]), - create_flag(2, [1]) - ] + expected = [ + create_flag(8), + create_flag(7, [8]), + create_flag(4, [8, 7]), + create_flag(6, [7, 4]), + create_flag(3, [6]), + create_flag(1, [3]), + create_flag(2, [1]) + ] - expect(TopologicalSort.sort(flags)).to eq(expected) + expect(TopologicalSort.sort(flags)).to eq(expected) + end + end end end end