lib/split/experiment.rb in split-0.5.0 vs lib/split/experiment.rb in split-0.6.0
- old
+ new
@@ -1,52 +1,150 @@
module Split
class Experiment
attr_accessor :name
attr_writer :algorithm
attr_accessor :resettable
+ attr_accessor :goals
+ attr_accessor :alternatives
- def initialize(name, options = {})
+ def initialize(name, options = {})
options = {
- :resettable => true,
- }.merge(options)
-
- @name = name.to_s
- @alternatives = options[:alternatives] if !options[:alternatives].nil?
-
- if !options[:algorithm].nil?
- @algorithm = options[:algorithm].is_a?(String) ? options[:algorithm].constantize : options[:algorithm]
+ :resettable => true,
+ }.merge(options)
+
+ @name = name.to_s
+
+ alts = options[:alternatives] || []
+
+ if alts.length == 1
+ if alts[0].is_a? Hash
+ alts = alts[0].map{|k,v| {k => v} }
+ end
end
-
- if !options[:resettable].nil?
- @resettable = options[:resettable].is_a?(String) ? options[:resettable] == 'true' : options[:resettable]
+
+ if alts.empty?
+ exp_config = Split.configuration.experiment_for(name)
+ if exp_config
+ alts = load_alternatives_from_configuration
+ options[:goals] = load_goals_from_configuration
+ options[:resettable] = exp_config[:resettable]
+ options[:algorithm] = exp_config[:algorithm]
+ end
end
-
- if !options[:alternative_names].nil?
- @alternatives = options[:alternative_names].map do |alternative|
- Split::Alternative.new(alternative, name)
- end
+
+ self.alternatives = alts
+ self.goals = options[:goals]
+ self.algorithm = options[:algorithm]
+ self.resettable = options[:resettable]
+ end
+
+ def self.all
+ Split.redis.smembers(:experiments).map {|e| find(e)}
+ end
+
+ def self.find(name)
+ if Split.redis.exists(name)
+ obj = self.new name
+ obj.load_from_redis
+ else
+ obj = nil
end
-
-
+ obj
end
-
- def algorithm
- @algorithm ||= Split.configuration.algorithm
+
+ def self.find_or_create(label, *alternatives)
+ experiment_name_with_version, goals = normalize_experiment(label)
+ name = experiment_name_with_version.to_s.split(':')[0]
+
+ exp = self.new name, :alternatives => alternatives, :goals => goals
+ exp.save
+ exp
end
+ def save
+ validate!
+
+ if new_record?
+ Split.redis.sadd(:experiments, name)
+ Split.redis.hset(:experiment_start_times, @name, Time.now)
+ @alternatives.reverse.each {|a| Split.redis.lpush(name, a.name)}
+ @goals.reverse.each {|a| Split.redis.lpush(goals_key, a)} unless @goals.nil?
+ else
+
+ existing_alternatives = load_alternatives_from_redis
+ existing_goals = load_goals_from_redis
+ unless existing_alternatives == @alternatives.map(&:name) && existing_goals == @goals
+ reset
+ @alternatives.each(&:delete)
+ delete_goals
+ Split.redis.del(@name)
+ @alternatives.reverse.each {|a| Split.redis.lpush(name, a.name)}
+ @goals.reverse.each {|a| Split.redis.lpush(goals_key, a)} unless @goals.nil?
+ end
+ end
+
+ Split.redis.hset(experiment_config_key, :resettable, resettable)
+ Split.redis.hset(experiment_config_key, :algorithm, algorithm.to_s)
+ self
+ end
+
+ def validate!
+ if @alternatives.empty? && Split.configuration.experiment_for(@name).nil?
+ raise ExperimentNotFound.new("Experiment #{@name} not found")
+ end
+ @alternatives.each {|a| a.validate! }
+ unless @goals.nil? || goals.kind_of?(Array)
+ raise ArgumentError, 'Goals must be an array'
+ end
+ end
+
+ def new_record?
+ !Split.redis.exists(name)
+ end
+
def ==(obj)
self.name == obj.name
end
+ def [](name)
+ alternatives.find{|a| a.name == name}
+ end
+
+ def algorithm
+ @algorithm ||= Split.configuration.algorithm
+ end
+
+ def algorithm=(algorithm)
+ @algorithm = algorithm.is_a?(String) ? algorithm.constantize : algorithm
+ end
+
+ def resettable=(resettable)
+ @resettable = resettable.is_a?(String) ? resettable == 'true' : resettable
+ end
+
+ def alternatives=(alts)
+ @alternatives = alts.map do |alternative|
+ if alternative.kind_of?(Split::Alternative)
+ alternative
+ else
+ Split::Alternative.new(alternative, @name)
+ end
+ end
+ end
+
def winner
if w = Split.redis.hget(:experiment_winner, name)
Split::Alternative.new(w, name)
else
nil
end
end
-
+
+ def winner=(winner_name)
+ Split.redis.hset(:experiment_winner, name, winner_name.to_s)
+ end
+
def participant_count
alternatives.inject(0){|sum,a| sum + a.participant_count}
end
def control
@@ -55,31 +153,15 @@
def reset_winner
Split.redis.hdel(:experiment_winner, name)
end
- def winner=(winner_name)
- Split.redis.hset(:experiment_winner, name, winner_name.to_s)
- end
-
def start_time
t = Split.redis.hget(:experiment_start_times, @name)
Time.parse(t) if t
end
-
- def [](name)
- alternatives.find{|a| a.name == name}
- end
- def alternatives
- @alternatives.dup
- end
-
- def alternative_names
- @alternatives.map(&:name)
- end
-
def next_alternative
winner || random_alternative
end
def random_alternative
@@ -104,10 +186,14 @@
else
name
end
end
+ def goals_key
+ "#{name}:goals"
+ end
+
def finished_key
"#{key}:finished"
end
def resettable?
@@ -123,141 +209,75 @@
def delete
alternatives.each(&:delete)
reset_winner
Split.redis.srem(:experiments, name)
Split.redis.del(name)
+ delete_goals
increment_version
end
- def new_record?
- !Split.redis.exists(name)
+ def delete_goals
+ Split.redis.del(goals_key)
end
- def save
- if new_record?
- Split.redis.sadd(:experiments, name)
- Split.redis.hset(:experiment_start_times, @name, Time.now)
- @alternatives.reverse.each {|a| Split.redis.lpush(name, a.name) }
- else
- Split.redis.del(name)
- @alternatives.reverse.each {|a| Split.redis.lpush(name, a.name) }
- end
- config_key = Split::Experiment.experiment_config_key(name)
- Split.redis.hset(config_key, :resettable, resettable)
- Split.redis.hset(config_key, :algorithm, algorithm.to_s)
- self
+ def load_from_redis
+ exp_config = Split.redis.hgetall(experiment_config_key)
+ self.resettable = exp_config['resettable']
+ self.algorithm = exp_config['algorithm']
+ self.alternatives = load_alternatives_from_redis
+ self.goals = load_goals_from_redis
end
- def self.load_alternatives_for(name)
- if Split.configuration.experiment_for(name)
- load_alternatives_from_configuration_for(name)
+ protected
+
+ def self.normalize_experiment(label)
+ if Hash === label
+ experiment_name = label.keys.first
+ goals = label.values.first
else
- load_alternatives_from_redis_for(name)
+ experiment_name = label
+ goals = []
end
+ return experiment_name, goals
end
- def self.load_alternatives_from_configuration_for(name)
- alts = Split.configuration.experiment_for(name)[:alternatives]
- raise ArgumentError, "Experiment configuration is missing :alternatives array" if alts.nil?
- if alts.is_a?(Hash)
- alts.keys
- else
- alts.flatten
- end
+ def experiment_config_key
+ "experiment_configurations/#{@name}"
end
- def self.load_alternatives_from_redis_for(name)
- case Split.redis.type(name)
- when 'set' # convert legacy sets to lists
- alts = Split.redis.smembers(name)
- Split.redis.del(name)
- alts.reverse.each {|a| Split.redis.lpush(name, a) }
- Split.redis.lrange(name, 0, -1)
+ def load_goals_from_configuration
+ goals = Split.configuration.experiment_for(@name)[:goals]
+ if goals.nil?
+ goals = []
else
- Split.redis.lrange(name, 0, -1)
+ goals.flatten
end
end
- def self.load_from_configuration(name)
- exp_config = Split.configuration.experiment_for(name) || {}
- self.new(name, :alternative_names => load_alternatives_for(name),
- :resettable => exp_config[:resettable],
- :algorithm => exp_config[:algorithm])
+ def load_goals_from_redis
+ Split.redis.lrange(goals_key, 0, -1)
end
- def self.load_from_redis(name)
- exp_config = Split.redis.hgetall(experiment_config_key(name))
- self.new(name, :alternative_names => load_alternatives_for(name),
- :resettable => exp_config['resettable'],
- :algorithm => exp_config['algorithm'])
- end
-
- def self.experiment_config_key(name)
- "experiment_configurations/#{name}"
- end
-
- def self.all
- Array(all_experiment_names_from_redis + all_experiment_names_from_configuration).map {|e| find(e)}
- end
-
- def self.all_experiment_names_from_redis
- Split.redis.smembers(:experiments)
- end
-
- def self.all_experiment_names_from_configuration
- Split.configuration.experiments ? Split.configuration.experiments.keys : []
- end
-
-
- def self.find(name)
- if Split.configuration.experiment_for(name)
- obj = load_from_configuration(name)
- elsif Split.redis.exists(name)
- obj = load_from_redis(name)
+ def load_alternatives_from_configuration
+ alts = Split.configuration.experiment_for(@name)[:alternatives]
+ raise ArgumentError, "Experiment configuration is missing :alternatives array" unless alts
+ if alts.is_a?(Hash)
+ alts.keys
else
- obj = nil
+ alts.flatten
end
- obj
end
- def self.find_or_create(key, *alternatives)
- name = key.to_s.split(':')[0]
-
- if alternatives.length == 1
- if alternatives[0].is_a? Hash
- alternatives = alternatives[0].map{|k,v| {k => v} }
- end
- end
-
- alts = initialize_alternatives(alternatives, name)
-
- if Split.redis.exists(name)
- existing_alternatives = load_alternatives_for(name)
- if existing_alternatives == alts.map(&:name)
- experiment = self.new(name, :alternative_names => alternatives)
- else
- exp = self.new(name, :alternative_names => existing_alternatives)
- exp.reset
- exp.alternatives.each(&:delete)
- experiment = self.new(name, :alternative_names =>alternatives)
- experiment.save
- end
+ def load_alternatives_from_redis
+ case Split.redis.type(@name)
+ when 'set' # convert legacy sets to lists
+ alts = Split.redis.smembers(@name)
+ Split.redis.del(@name)
+ alts.reverse.each {|a| Split.redis.lpush(@name, a) }
+ Split.redis.lrange(@name, 0, -1)
else
- experiment = self.new(name, :alternative_names => alternatives)
- experiment.save
+ Split.redis.lrange(@name, 0, -1)
end
- return experiment
-
end
- def self.initialize_alternatives(alternatives, name)
-
- unless alternatives.all? { |a| Split::Alternative.valid?(a) }
- raise ArgumentError, 'Alternatives must be strings'
- end
-
- alternatives.map do |alternative|
- Split::Alternative.new(alternative, name)
- end
- end
end
end