From 6db8ee173229993538e0b99e1161d6891fc9c1e4 Mon Sep 17 00:00:00 2001 From: Fabricio Campos Date: Tue, 25 Nov 2025 19:19:46 -0300 Subject: [PATCH 1/4] refactor: move evaluation classes into AmplitudeAnalytics module --- lib/experiment/evaluation/evaluation.rb | 498 +++++++++--------- lib/experiment/evaluation/flag.rb | 178 +++---- lib/experiment/evaluation/murmur3.rb | 176 ++++--- lib/experiment/evaluation/select.rb | 20 +- lib/experiment/evaluation/semantic_version.rb | 72 +-- lib/experiment/evaluation/topological_sort.rb | 84 +-- lib/experiment/flag/flag_config_fetcher.rb | 2 +- lib/experiment/local/client.rb | 4 +- .../deployment/deployment_runner_spec.rb | 2 +- .../evaluation/evaluation_intgration_spec.rb | 6 +- spec/experiment/evaluation/murmur3_spec.rb | 2 +- spec/experiment/evaluation/select_spec.rb | 2 +- .../evaluation/semantic_version_spec.rb | 10 +- .../evaluation/topological_sort_spec.rb | 46 +- spec/experiment/local/flag_config_spec.rb | 2 +- 15 files changed, 558 insertions(+), 546 deletions(-) diff --git a/lib/experiment/evaluation/evaluation.rb b/lib/experiment/evaluation/evaluation.rb index 9aa72c5..9fb80d7 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 AmplitudeAnalytics + 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) + AmplitudeAnalytics::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, + AmplitudeAnalytics::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..e8f4fb5 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 AmplitudeAnalytics + 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..16c75ce 100644 --- a/lib/experiment/evaluation/murmur3.rb +++ b/lib/experiment/evaluation/murmur3.rb @@ -1,104 +1,106 @@ # 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 AmplitudeAnalytics + # 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 - 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 = 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 diff --git a/lib/experiment/evaluation/select.rb b/lib/experiment/evaluation/select.rb index 94e327b..918ad03 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 AmplitudeAnalytics + # 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? - 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..803c916 100644 --- a/lib/experiment/evaluation/semantic_version.rb +++ b/lib/experiment/evaluation/semantic_version.rb @@ -1,52 +1,54 @@ # frozen_string_literal: true -class SemanticVersion - include Comparable +module AmplitudeAnalytics + 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?(AmplitudeAnalytics::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 diff --git a/lib/experiment/evaluation/topological_sort.rb b/lib/experiment/evaluation/topological_sort.rb index 8c4cac1..a0e8aad 100644 --- a/lib/experiment/evaluation/topological_sort.rb +++ b/lib/experiment/evaluation/topological_sort.rb @@ -1,56 +1,58 @@ # frozen_string_literal: true -class CycleError < StandardError - attr_accessor :path +module AmplitudeAnalytics + class CycleError < StandardError + attr_accessor :path - def initialize(path) - super("Detected a cycle between flags #{path}") - self.path = path - end -end - -# 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 + def initialize(path) + super("Detected a cycle between flags #{path}") + self.path = path 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 + # 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 - # No dependencies - return flag and remove from available - if !flag.dependencies || flag.dependencies.empty? - available.delete(flag.key) - return [flag] + starting_keys.each do |flag_key| + traversal = parent_traversal(flag_key, available) + result.concat(traversal) if traversal + end + + result end - # Check for cycles - path.push(flag.key) - result = [] + # Perform depth-first traversal of flag dependencies + def self.parent_traversal(flag_key, available, path = []) + flag = available[flag_key] + return nil unless flag - flag.dependencies.each do |parent_key| - raise CycleError, path if path.any? { |p| p == parent_key } + # No dependencies - return flag and remove from available + if !flag.dependencies || flag.dependencies.empty? + available.delete(flag.key) + return [flag] + end - traversal = parent_traversal(parent_key, available, path) - result.concat(traversal) if traversal - end + # Check for cycles + path.push(flag.key) + result = [] + + flag.dependencies.each do |parent_key| + raise AmplitudeAnalytics::CycleError, path if path.any? { |p| p == parent_key } - result.push(flag) - path.pop - available.delete(flag.key) + traversal = parent_traversal(parent_key, available, path) + result.concat(traversal) if traversal + end - result + result.push(flag) + path.pop + available.delete(flag.key) + + result + end end end diff --git a/lib/experiment/flag/flag_config_fetcher.rb b/lib/experiment/flag/flag_config_fetcher.rb index 1a26ecf..3a59dfd 100644 --- a/lib/experiment/flag/flag_config_fetcher.rb +++ b/lib/experiment/flag/flag_config_fetcher.rb @@ -44,7 +44,7 @@ def fetch_v2 raise "flagConfigs - received error response: #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPOK) @logger.debug("[Experiment] Fetch flag configs: #{response.body}") - JSON.parse(response.body).map { |f| Evaluation::Flag.from_hash(f) } + JSON.parse(response.body).map { |f| AmplitudeAnalytics::Evaluation::Flag.from_hash(f) } end # Fetch local evaluation mode flag configs from the Experiment API server. diff --git a/lib/experiment/local/client.rb b/lib/experiment/local/client.rb index 2865888..e507fea 100644 --- a/lib/experiment/local/client.rb +++ b/lib/experiment/local/client.rb @@ -19,7 +19,7 @@ def initialize(api_key, config = nil) @flags_mutex = Mutex.new raise ArgumentError, 'Experiment API key is empty' if @api_key.nil? || @api_key.empty? - @engine = Evaluation::Engine.new + @engine = AmplitudeAnalytics::Evaluation::Engine.new @assignment_service = nil @assignment_service = AssignmentService.new(AmplitudeAnalytics::Amplitude.new(config.assignment_config.api_key, configuration: config.assignment_config), AssignmentFilter.new(config.assignment_config.cache_capacity)) if config&.assignment_config @@ -63,7 +63,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 = AmplitudeAnalytics::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/deployment/deployment_runner_spec.rb b/spec/experiment/deployment/deployment_runner_spec.rb index b2fe03f..f731170 100644 --- a/spec/experiment/deployment/deployment_runner_spec.rb +++ b/spec/experiment/deployment/deployment_runner_spec.rb @@ -3,7 +3,7 @@ module AmplitudeExperiment describe DeploymentRunner do let(:cohort_id) { '1234' } before(:each) do - @flag = Evaluation::Flag.from_hash( + @flag = AmplitudeAnalytics::Evaluation::Flag.from_hash( { 'key' => 'flag', 'variants' => {}, diff --git a/spec/experiment/evaluation/evaluation_intgration_spec.rb b/spec/experiment/evaluation/evaluation_intgration_spec.rb index 57b4ab8..eda9087 100644 --- a/spec/experiment/evaluation/evaluation_intgration_spec.rb +++ b/spec/experiment/evaluation/evaluation_intgration_spec.rb @@ -3,9 +3,9 @@ require 'net/http' require 'json' -describe Evaluation::Engine do +describe AmplitudeAnalytics::Evaluation::Engine do let(:deployment_key) { 'server-NgJxxvg8OGwwBsWVXqyxQbdiflbhvugy' } - let(:engine) { Evaluation::Engine.new } + let(:engine) { AmplitudeAnalytics::Evaluation::Engine.new } let(:flags) { get_flags(deployment_key) } describe 'basic tests' do @@ -490,6 +490,6 @@ def get_flags(deployment_key) 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| AmplitudeAnalytics::Evaluation::Flag.from_hash(flag) } end end diff --git a/spec/experiment/evaluation/murmur3_spec.rb b/spec/experiment/evaluation/murmur3_spec.rb index 6cde098..e407ac5 100644 --- a/spec/experiment/evaluation/murmur3_spec.rb +++ b/spec/experiment/evaluation/murmur3_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Murmur3 do +RSpec.describe AmplitudeAnalytics::Murmur3 do let(:murmur_seed) { 0x7f3a21ea } describe '.hash32x86' do diff --git a/spec/experiment/evaluation/select_spec.rb b/spec/experiment/evaluation/select_spec.rb index cd7b8d9..b4d9e33 100644 --- a/spec/experiment/evaluation/select_spec.rb +++ b/spec/experiment/evaluation/select_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Evaluation do +RSpec.describe AmplitudeAnalytics::Evaluation do let(:primitive_object) do { 'null' => nil, diff --git a/spec/experiment/evaluation/semantic_version_spec.rb b/spec/experiment/evaluation/semantic_version_spec.rb index 91af52f..a1218ea 100644 --- a/spec/experiment/evaluation/semantic_version_spec.rb +++ b/spec/experiment/evaluation/semantic_version_spec.rb @@ -1,15 +1,15 @@ -describe SemanticVersion do +describe AmplitudeAnalytics::SemanticVersion do def assert_invalid_version(version) - expect(SemanticVersion.parse(version)).to be_nil + expect(AmplitudeAnalytics::SemanticVersion.parse(version)).to be_nil end def assert_valid_version(version) - expect(SemanticVersion.parse(version)).not_to be_nil + expect(AmplitudeAnalytics::SemanticVersion.parse(version)).not_to be_nil end def assert_version_comparison(v1, op, v2) - sv1 = SemanticVersion.parse(v1) - sv2 = SemanticVersion.parse(v2) + sv1 = AmplitudeAnalytics::SemanticVersion.parse(v1) + sv2 = AmplitudeAnalytics::SemanticVersion.parse(v2) expect(sv1).not_to be_nil expect(sv2).not_to be_nil return if sv1.nil? || sv2.nil? diff --git a/spec/experiment/evaluation/topological_sort_spec.rb b/spec/experiment/evaluation/topological_sort_spec.rb index 9cea294..f083ff4 100644 --- a/spec/experiment/evaluation/topological_sort_spec.rb +++ b/spec/experiment/evaluation/topological_sort_spec.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -RSpec.describe TopologicalSort do +RSpec.describe AmplitudeAnalytics::TopologicalSort do def create_flag(key, dependencies = nil) - Evaluation::Flag.from_hash({ + AmplitudeAnalytics::Evaluation::Flag.from_hash({ 'key' => key.to_s, 'variants' => {}, 'segments' => [], @@ -12,22 +12,22 @@ def create_flag(key, dependencies = nil) describe '.sort' do it 'handles empty flag list' do - expect(TopologicalSort.sort({})).to eq([]) - expect(TopologicalSort.sort({}, ['1'])).to eq([]) + expect(AmplitudeAnalytics::TopologicalSort.sort({})).to eq([]) + expect(AmplitudeAnalytics::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([]) + expect(AmplitudeAnalytics::TopologicalSort.sort(flags)).to eq([create_flag(1)]) + expect(AmplitudeAnalytics::TopologicalSort.sort(flags, ['1'])).to eq([create_flag(1)]) + expect(AmplitudeAnalytics::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([]) + expect(AmplitudeAnalytics::TopologicalSort.sort(flags)).to eq([create_flag(1, [2])]) + expect(AmplitudeAnalytics::TopologicalSort.sort(flags, ['1'])).to eq([create_flag(1, [2])]) + expect(AmplitudeAnalytics::TopologicalSort.sort(flags, ['999'])).to eq([]) end it 'handles multiple flags without dependencies' do @@ -35,9 +35,9 @@ def create_flag(key, dependencies = nil) '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([]) + expect(AmplitudeAnalytics::TopologicalSort.sort(flags)).to eq([create_flag(1), create_flag(2)]) + expect(AmplitudeAnalytics::TopologicalSort.sort(flags, %w[1 2])).to eq([create_flag(1), create_flag(2)]) + expect(AmplitudeAnalytics::TopologicalSort.sort(flags, %w[99 999])).to eq([]) end it 'handles multiple flags with dependencies' do @@ -47,16 +47,16 @@ def create_flag(key, dependencies = nil) '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([]) + expect(AmplitudeAnalytics::TopologicalSort.sort(flags)).to eq(expected) + expect(AmplitudeAnalytics::TopologicalSort.sort(flags, %w[1 2])).to eq(expected) + expect(AmplitudeAnalytics::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 + expect { AmplitudeAnalytics::TopologicalSort.sort(flags) }.to raise_error(AmplitudeAnalytics::CycleError) { |e| expect(e.path).to eq ['1'] } + expect { AmplitudeAnalytics::TopologicalSort.sort(flags, ['1']) }.to raise_error(AmplitudeAnalytics::CycleError) { |e| expect(e.path).to eq ['1'] } + expect { AmplitudeAnalytics::TopologicalSort.sort(flags, ['999']) }.not_to raise_error end it 'detects cycles between two flags' do @@ -64,9 +64,9 @@ def create_flag(key, dependencies = nil) '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 + expect { AmplitudeAnalytics::TopologicalSort.sort(flags) }.to raise_error(AmplitudeAnalytics::CycleError) { |e| expect(e.path).to eq %w[1 2] } + expect { AmplitudeAnalytics::TopologicalSort.sort(flags, ['2']) }.to raise_error(AmplitudeAnalytics::CycleError) { |e| expect(e.path).to eq %w[2 1] } + expect { AmplitudeAnalytics::TopologicalSort.sort(flags, ['999']) }.not_to raise_error end it 'handles complex dependencies without cycles' do @@ -90,7 +90,7 @@ def create_flag(key, dependencies = nil) create_flag(2, [1]) ] - expect(TopologicalSort.sort(flags)).to eq(expected) + expect(AmplitudeAnalytics::TopologicalSort.sort(flags)).to eq(expected) end end end diff --git a/spec/experiment/local/flag_config_spec.rb b/spec/experiment/local/flag_config_spec.rb index cec9cec..9677625 100644 --- a/spec/experiment/local/flag_config_spec.rb +++ b/spec/experiment/local/flag_config_spec.rb @@ -118,7 +118,7 @@ module AmplitudeExperiment } } } - }.transform_values { |f| Evaluation::Flag.from_hash(f) } + }.transform_values { |f| AmplitudeAnalytics::Evaluation::Flag.from_hash(f) } end describe '#get_all_cohort_ids_from_flag' do From c571fe4dd94bca307a5694d73730b0eed0a5273e Mon Sep 17 00:00:00 2001 From: Fabricio Campos Date: Tue, 25 Nov 2025 19:20:21 -0300 Subject: [PATCH 2/4] chore: fix contributing guide link --- .github/pull_request_template.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 5936fd6..253c877 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 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?: From 399b9f6da589e84b61581aca9d9307a50f335472 Mon Sep 17 00:00:00 2001 From: Fabricio Campos Date: Tue, 25 Nov 2025 19:37:23 -0300 Subject: [PATCH 3/4] refactor: remove CycleError that was duplicated and not being used --- lib/experiment/error.rb | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/lib/experiment/error.rb b/lib/experiment/error.rb index bfd859d..5c68c94 100644 --- a/lib/experiment/error.rb +++ b/lib/experiment/error.rb @@ -31,18 +31,4 @@ def initialize(status_code, cohort_id, message) @status_code = status_code end end - - class CycleError < StandardError - # Raised when topological sorting encounters a cycle between flag dependencies. - attr_reader :path - - def initialize(path) - super - @path = path - end - - def to_s - "Detected a cycle between flags #{@path}" - end - end end From 0c2173f9ec9eed98da7aae765645777d861f5a01 Mon Sep 17 00:00:00 2001 From: Fabricio Campos Date: Mon, 1 Dec 2025 15:35:19 -0300 Subject: [PATCH 4/4] update concurrent-ruby gem to support newer versions --- amplitude-experiment.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amplitude-experiment.gemspec b/amplitude-experiment.gemspec index 42a8b5a..18d27fc 100644 --- a/amplitude-experiment.gemspec +++ b/amplitude-experiment.gemspec @@ -18,7 +18,7 @@ Gem::Specification.new do |spec| spec.require_paths = ['lib'] spec.extra_rdoc_files = ['README.md'] - spec.add_dependency 'concurrent-ruby', '~> 1.2.2' + spec.add_dependency 'concurrent-ruby', '~> 1.2' spec.add_development_dependency 'psych', '~> 4.0' spec.add_development_dependency 'rake', '~> 13.0'