lib/split/experiment.rb in split-3.4.1 vs lib/split/experiment.rb in split-4.0.0.pre
- old
+ new
@@ -1,6 +1,9 @@
# frozen_string_literal: true
+
+require 'rubystats'
+
module Split
class Experiment
attr_accessor :name
attr_accessor :goals
attr_accessor :alternative_probabilities
@@ -11,50 +14,47 @@
DEFAULT_OPTIONS = {
:resettable => true
}
+ def self.find(name)
+ Split.cache(:experiments, name) do
+ return unless Split.redis.exists?(name)
+ Experiment.new(name).tap { |exp| exp.load_from_redis }
+ end
+ end
+
def initialize(name, options = {})
options = DEFAULT_OPTIONS.merge(options)
@name = name.to_s
- alternatives = extract_alternatives_from_options(options)
-
- if alternatives.empty? && (exp_config = Split.configuration.experiment_for(name))
- options = {
- alternatives: load_alternatives_from_configuration,
- goals: Split::GoalsCollection.new(@name).load_from_configuration,
- metadata: load_metadata_from_configuration,
- resettable: exp_config[:resettable],
- algorithm: exp_config[:algorithm]
- }
- else
- options[:alternatives] = alternatives
- end
-
- set_alternatives_and_options(options)
+ extract_alternatives_from_options(options)
end
def self.finished_key(key)
"#{key}:finished"
end
def set_alternatives_and_options(options)
- self.alternatives = options[:alternatives]
- self.goals = options[:goals]
- self.resettable = options[:resettable]
- self.algorithm = options[:algorithm]
- self.metadata = options[:metadata]
+ options_with_defaults = DEFAULT_OPTIONS.merge(
+ options.reject { |k, v| v.nil? }
+ )
+
+ self.alternatives = options_with_defaults[:alternatives]
+ self.goals = options_with_defaults[:goals]
+ self.resettable = options_with_defaults[:resettable]
+ self.algorithm = options_with_defaults[:algorithm]
+ self.metadata = options_with_defaults[:metadata]
end
def extract_alternatives_from_options(options)
alts = options[:alternatives] || []
if alts.length == 1
if alts[0].is_a? Hash
- alts = alts[0].map{|k,v| {k => v} }
+ alts = alts[0].map{|k, v| {k => v} }
end
end
if alts.empty?
exp_config = Split.configuration.experiment_for(name)
@@ -85,12 +85,12 @@
elsif experiment_configuration_has_changed?
reset unless Split.configuration.reset_manually
persist_experiment_configuration
end
- redis.hset(experiment_config_key, :resettable, resettable)
- redis.hset(experiment_config_key, :algorithm, algorithm.to_s)
+ redis.hset(experiment_config_key, :resettable, resettable,
+ :algorithm, algorithm.to_s)
self
end
def validate!
if @alternatives.empty? && Split.configuration.experiment_for(@name).nil?
@@ -99,11 +99,11 @@
@alternatives.each {|a| a.validate! }
goals_collection.validate!
end
def new_record?
- !redis.exists(name)
+ ExperimentCatalog.find(name).nil?
end
def ==(obj)
self.name == obj.name
end
@@ -133,15 +133,17 @@
end
end
end
def winner
- experiment_winner = redis.hget(:experiment_winner, name)
- if experiment_winner
- Split::Alternative.new(experiment_winner, name)
- else
- nil
+ Split.cache(:experiment_winner, name) do
+ experiment_winner = redis.hget(:experiment_winner, name)
+ if experiment_winner
+ Split::Alternative.new(experiment_winner, name)
+ else
+ nil
+ end
end
end
def has_winner?
return @has_winner if defined? @has_winner
@@ -149,37 +151,41 @@
end
def winner=(winner_name)
redis.hset(:experiment_winner, name, winner_name.to_s)
@has_winner = true
+ Split.configuration.on_experiment_winner_choose.call(self)
end
def participant_count
- alternatives.inject(0){|sum,a| sum + a.participant_count}
+ alternatives.inject(0){|sum, a| sum + a.participant_count}
end
def control
alternatives.first
end
def reset_winner
redis.hdel(:experiment_winner, name)
@has_winner = false
+ Split::Cache.clear_key(@name)
end
def start
redis.hset(:experiment_start_times, @name, Time.now.to_i)
end
def start_time
- t = redis.hget(:experiment_start_times, @name)
- if t
- # Check if stored time is an integer
- if t =~ /^[-+]?[0-9]+$/
- Time.at(t.to_i)
- else
- Time.parse(t)
+ Split.cache(:experiment_start_times, @name) do
+ t = redis.hget(:experiment_start_times, @name)
+ if t
+ # Check if stored time is an integer
+ if t =~ /^[-+]?[0-9]+$/
+ Time.at(t.to_i)
+ else
+ Time.parse(t)
+ end
end
end
end
def next_alternative
@@ -226,10 +232,11 @@
resettable
end
def reset
Split.configuration.on_before_experiment_reset.call(self)
+ Split::Cache.clear_key(@name)
alternatives.each(&:reset)
reset_winner
Split.configuration.on_experiment_reset.call(self)
increment_version
end
@@ -239,10 +246,11 @@
if Split.configuration.start_manually
redis.hdel(:experiment_start_times, @name)
end
reset_winner
redis.srem(:experiments, name)
+ remove_experiment_cohorting
remove_experiment_configuration
Split.configuration.on_experiment_delete.call(self)
increment_version
end
@@ -336,32 +344,28 @@
return winning_counts
end
def find_simulated_winner(simulated_cr_hash)
# figure out which alternative had the highest simulated conversion rate
- winning_pair = ["",0.0]
+ winning_pair = ["", 0.0]
simulated_cr_hash.each do |alternative, rate|
if rate > winning_pair[1]
winning_pair = [alternative, rate]
end
end
winner = winning_pair[0]
return winner
end
def calc_simulated_conversion_rates(beta_params)
- # initialize a random variable (from which to simulate conversion rates ~beta-distributed)
- rand = SimpleRandom.new
- rand.set_seed
-
simulated_cr_hash = {}
# create a hash which has the conversion rate pulled from each alternative's beta distribution
beta_params.each do |alternative, params|
alpha = params[0]
beta = params[1]
- simulated_conversion_rate = rand.beta(alpha, beta)
+ simulated_conversion_rate = Rubystats::BetaDistribution.new(alpha, beta).rng
simulated_cr_hash[alternative] = simulated_conversion_rate
end
return simulated_cr_hash
end
@@ -395,10 +399,27 @@
name + "-" + goal
end
js_id.gsub('/', '--')
end
+ def cohorting_disabled?
+ @cohorting_disabled ||= begin
+ value = redis.hget(experiment_config_key, :cohorting)
+ value.nil? ? false : value.downcase == "true"
+ end
+ end
+
+ def disable_cohorting
+ @cohorting_disabled = true
+ redis.hset(experiment_config_key, :cohorting, true)
+ end
+
+ def enable_cohorting
+ @cohorting_disabled = false
+ redis.hset(experiment_config_key, :cohorting, false)
+ end
+
protected
def experiment_config_key
"experiment_configurations/#{@name}"
end
@@ -421,19 +442,11 @@
alts.flatten
end
end
def load_alternatives_from_redis
- alternatives = case redis.type(@name)
- when 'set' # convert legacy sets to lists
- alts = redis.smembers(@name)
- redis.del(@name)
- alts.reverse.each {|a| redis.lpush(@name, a) }
- redis.lrange(@name, 0, -1)
- else
- redis.lrange(@name, 0, -1)
- end
+ alternatives = redis.lrange(@name, 0, -1)
alternatives.map do |alt|
alt = begin
JSON.parse(alt)
rescue
alt
@@ -454,29 +467,38 @@
def persist_experiment_configuration
redis_interface.add_to_set(:experiments, name)
redis_interface.persist_list(name, @alternatives.map{|alt| {alt.name => alt.weight}.to_json})
goals_collection.save
- redis.set(metadata_key, @metadata.to_json) unless @metadata.nil?
+
+ if @metadata
+ redis.set(metadata_key, @metadata.to_json)
+ else
+ delete_metadata
+ end
end
def remove_experiment_configuration
@alternatives.each(&:delete)
goals_collection.delete
delete_metadata
redis.del(@name)
end
def experiment_configuration_has_changed?
- existing_alternatives = load_alternatives_from_redis
- existing_goals = Split::GoalsCollection.new(@name).load_from_redis
- existing_metadata = load_metadata_from_redis
- existing_alternatives.map(&:to_s) != @alternatives.map(&:to_s) ||
- existing_goals != @goals ||
- existing_metadata != @metadata
+ existing_experiment = Experiment.find(@name)
+
+ existing_experiment.alternatives.map(&:to_s) != @alternatives.map(&:to_s) ||
+ existing_experiment.goals != @goals ||
+ existing_experiment.metadata != @metadata
end
def goals_collection
Split::GoalsCollection.new(@name, @goals)
+ end
+
+ def remove_experiment_cohorting
+ @cohorting_disabled = false
+ redis.hdel(experiment_config_key, :cohorting)
end
end
end