verdict 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +15 -0
- data/.gitignore +17 -0
- data/.travis.yml +13 -0
- data/Gemfile +6 -0
- data/LICENSE +20 -0
- data/README.md +89 -0
- data/Rakefile +11 -0
- data/lib/verdict.rb +54 -0
- data/lib/verdict/assignment.rb +61 -0
- data/lib/verdict/conversion.rb +32 -0
- data/lib/verdict/event_logger.rb +20 -0
- data/lib/verdict/experiment.rb +220 -0
- data/lib/verdict/group.rb +36 -0
- data/lib/verdict/metadata.rb +30 -0
- data/lib/verdict/railtie.rb +12 -0
- data/lib/verdict/segmenter.rb +78 -0
- data/lib/verdict/storage.rb +138 -0
- data/lib/verdict/tasks.rake +59 -0
- data/lib/verdict/version.rb +3 -0
- data/test/assignment_test.rb +76 -0
- data/test/conversion_test.rb +37 -0
- data/test/event_logger_test.rb +43 -0
- data/test/experiment_test.rb +280 -0
- data/test/experiments_repository_test.rb +32 -0
- data/test/group_test.rb +42 -0
- data/test/memory_subject_storage_test.rb +45 -0
- data/test/metadata_test.rb +44 -0
- data/test/redis_subject_storage_test.rb +89 -0
- data/test/static_percentage_segmenter_test.rb +94 -0
- data/test/test_helper.rb +10 -0
- data/verdict.gemspec +25 -0
- metadata +157 -0
@@ -0,0 +1,36 @@
|
|
1
|
+
class Verdict::Group
|
2
|
+
include Verdict::Metadata
|
3
|
+
|
4
|
+
attr_reader :experiment, :handle
|
5
|
+
|
6
|
+
def initialize(experiment, handle)
|
7
|
+
@experiment, @handle = experiment, handle.to_s
|
8
|
+
end
|
9
|
+
|
10
|
+
def to_s
|
11
|
+
handle
|
12
|
+
end
|
13
|
+
|
14
|
+
def to_sym
|
15
|
+
handle.to_sym
|
16
|
+
end
|
17
|
+
|
18
|
+
def ===(other)
|
19
|
+
case other
|
20
|
+
when Verdict::Group; experiment == other.experiment && other.handle == handle
|
21
|
+
when Symbol, String; handle == other.to_s
|
22
|
+
else false
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def as_json(options = {})
|
27
|
+
{
|
28
|
+
handle: handle,
|
29
|
+
metadata: metadata
|
30
|
+
}
|
31
|
+
end
|
32
|
+
|
33
|
+
def to_json(options = {})
|
34
|
+
as_json(options).to_json
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Verdict::Metadata
|
2
|
+
|
3
|
+
def self.included(klass)
|
4
|
+
klass.send(:attr_reader, :metadata)
|
5
|
+
end
|
6
|
+
|
7
|
+
def name(new_name = nil)
|
8
|
+
@metadata ||= {}
|
9
|
+
return @metadata[:name] if new_name.nil?
|
10
|
+
@metadata[:name] = new_name
|
11
|
+
end
|
12
|
+
|
13
|
+
def description(new_description = nil)
|
14
|
+
@metadata ||= {}
|
15
|
+
return @metadata[:description] if new_description.nil?
|
16
|
+
@metadata[:description] = new_description
|
17
|
+
end
|
18
|
+
|
19
|
+
def screenshot(new_screenshot = nil)
|
20
|
+
@metadata ||= {}
|
21
|
+
return @metadata[:screenshot] if new_screenshot.nil?
|
22
|
+
@metadata[:screenshot] = new_screenshot
|
23
|
+
end
|
24
|
+
|
25
|
+
def owner(new_owner = nil)
|
26
|
+
@metadata ||= {}
|
27
|
+
return @metadata[:owner] if new_owner.nil?
|
28
|
+
@metadata[:owner] = new_owner
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
class Verdict::Railtie < Rails::Railtie
|
2
|
+
initializer "experiments.configure_rails_initialization" do |app|
|
3
|
+
Verdict.default_logger = Rails.logger
|
4
|
+
Verdict.directory = Rails.root.join('app', 'experiments')
|
5
|
+
|
6
|
+
app.config.eager_load_paths -= [Verdict.directory.to_s]
|
7
|
+
end
|
8
|
+
|
9
|
+
rake_tasks do
|
10
|
+
load File.expand_path("./tasks.rake", File.dirname(__FILE__))
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
require 'digest/md5'
|
2
|
+
|
3
|
+
module Verdict::Segmenter
|
4
|
+
|
5
|
+
class Base
|
6
|
+
|
7
|
+
attr_reader :experiment, :groups
|
8
|
+
|
9
|
+
def initialize(experiment)
|
10
|
+
@experiment = experiment
|
11
|
+
@groups = {}
|
12
|
+
end
|
13
|
+
|
14
|
+
def verify!
|
15
|
+
end
|
16
|
+
|
17
|
+
def group(identifier, subject, context)
|
18
|
+
raise NotImplementedError
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
class StaticPercentage < Base
|
23
|
+
|
24
|
+
class Group < Verdict::Group
|
25
|
+
|
26
|
+
attr_reader :percentile_range
|
27
|
+
|
28
|
+
def initialize(experiment, handle, percentile_range)
|
29
|
+
super(experiment, handle)
|
30
|
+
@percentile_range = percentile_range
|
31
|
+
end
|
32
|
+
|
33
|
+
def percentage_size
|
34
|
+
percentile_range.end - percentile_range.begin
|
35
|
+
end
|
36
|
+
|
37
|
+
def to_s
|
38
|
+
"#{handle} (#{percentage_size}%)"
|
39
|
+
end
|
40
|
+
|
41
|
+
def as_json(options = {})
|
42
|
+
super(options).merge(percentage: percentage_size)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def initialize(experiment)
|
47
|
+
super
|
48
|
+
@total_percentage_segmented = 0
|
49
|
+
end
|
50
|
+
|
51
|
+
def verify!
|
52
|
+
raise Verdict::SegmentationError, "Should segment exactly 100% of the cases, but segments add up to #{@total_percentage_segmented}%." if @total_percentage_segmented != 100
|
53
|
+
end
|
54
|
+
|
55
|
+
def group(handle, size, &block)
|
56
|
+
percentage = size.kind_of?(Hash) && size[:percentage] ? size[:percentage] : size
|
57
|
+
n = case percentage
|
58
|
+
when :rest; 100 - @total_percentage_segmented
|
59
|
+
when :half; 50
|
60
|
+
when Integer; percentage
|
61
|
+
else Integer(percentage)
|
62
|
+
end
|
63
|
+
|
64
|
+
group = Group.new(experiment, handle, @total_percentage_segmented ... (@total_percentage_segmented + n))
|
65
|
+
@groups[group.handle] = group
|
66
|
+
@total_percentage_segmented += n
|
67
|
+
group.instance_eval(&block) if block_given?
|
68
|
+
return group
|
69
|
+
end
|
70
|
+
|
71
|
+
def assign(identifier, subject, context)
|
72
|
+
percentile = Digest::MD5.hexdigest("#{@experiment.handle}#{identifier}").to_i(16) % 100
|
73
|
+
_, group = groups.find { |_, group| group.percentile_range.include?(percentile) }
|
74
|
+
raise Verdict::SegmentationError, "Could not get segment for subject #{identifier.inspect}!" unless group
|
75
|
+
group
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,138 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
module Verdict::Storage
|
4
|
+
|
5
|
+
class MockStorage
|
6
|
+
|
7
|
+
# Should store the assignments to allow quick lookups.
|
8
|
+
# - Assignments should be unique on the combination of
|
9
|
+
# `assignment.experiment.handle` and `assignment.subject_identifier`.
|
10
|
+
# - The main property to store is `group.handle`
|
11
|
+
# - Should return true if stored successfully.
|
12
|
+
def store_assignment(assignment)
|
13
|
+
false
|
14
|
+
end
|
15
|
+
|
16
|
+
# Should do a fast lookup of an assignment of the subject for the given experiment.
|
17
|
+
# - Should return nil if not found in store
|
18
|
+
# - Should return an Assignment instance otherwise.
|
19
|
+
def retrieve_assignment(experiment, subject_identifier)
|
20
|
+
nil
|
21
|
+
end
|
22
|
+
|
23
|
+
# Should remove the subject from storage, so it will be reassigned later.
|
24
|
+
def remove_assignment(experiment, subject_identifier)
|
25
|
+
end
|
26
|
+
|
27
|
+
# Should clear out the storage used for this experiment
|
28
|
+
def clear_experiment(experiment)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Retrieves the start timestamp of the experiment
|
32
|
+
def retrieve_start_timestamp(experiment)
|
33
|
+
nil
|
34
|
+
end
|
35
|
+
|
36
|
+
# Stores the timestamp on which the experiment was started
|
37
|
+
def store_start_timestamp(experiment, timestamp)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
class MemoryStorage
|
42
|
+
|
43
|
+
attr_reader :assignments, :start_timestamps
|
44
|
+
|
45
|
+
def initialize
|
46
|
+
@assignments = {}
|
47
|
+
@start_timestamps = {}
|
48
|
+
end
|
49
|
+
|
50
|
+
def store_assignment(assignment)
|
51
|
+
@assignments[assignment.experiment.handle] ||= {}
|
52
|
+
@assignments[assignment.experiment.handle][assignment.subject_identifier] = assignment.returning
|
53
|
+
true
|
54
|
+
end
|
55
|
+
|
56
|
+
def retrieve_assignment(experiment, subject_identifier)
|
57
|
+
experiment_store = @assignments[experiment.handle] || {}
|
58
|
+
experiment_store[subject_identifier]
|
59
|
+
end
|
60
|
+
|
61
|
+
def remove_assignment(experiment, subject_identifier)
|
62
|
+
@assignments[assignment.experiment.handle] ||= {}
|
63
|
+
@assignments[assignment.experiment.handle].delete(subject_identifier)
|
64
|
+
end
|
65
|
+
|
66
|
+
def clear_experiment(experiment)
|
67
|
+
@assignments.delete(experiment.handle)
|
68
|
+
end
|
69
|
+
|
70
|
+
def retrieve_start_timestamp(experiment)
|
71
|
+
@start_timestamps[experiment.handle]
|
72
|
+
end
|
73
|
+
|
74
|
+
def store_start_timestamp(experiment, timestamp)
|
75
|
+
@start_timestamps[experiment.handle] = timestamp
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
class RedisStorage
|
80
|
+
|
81
|
+
attr_accessor :redis, :key_prefix
|
82
|
+
|
83
|
+
def initialize(redis = nil, options = {})
|
84
|
+
@redis = redis
|
85
|
+
@key_prefix = options[:key_prefix] || 'experiments/'
|
86
|
+
end
|
87
|
+
|
88
|
+
def retrieve_assignment(experiment, subject_identifier)
|
89
|
+
if value = redis.hget(generate_experiment_key(experiment), subject_identifier)
|
90
|
+
hash = JSON.parse(value)
|
91
|
+
experiment.subject_assignment(
|
92
|
+
subject_identifier,
|
93
|
+
experiment.group(hash['group']),
|
94
|
+
DateTime.parse(hash['created_at']).to_time
|
95
|
+
)
|
96
|
+
end
|
97
|
+
rescue ::Redis::BaseError => e
|
98
|
+
raise Verdict::StorageError, "Redis error: #{e.message}"
|
99
|
+
end
|
100
|
+
|
101
|
+
def store_assignment(assignment)
|
102
|
+
hash = { group: assignment.handle, created_at: assignment.created_at }
|
103
|
+
redis.hset(generate_experiment_key(assignment.experiment), assignment.subject_identifier, JSON.dump(hash))
|
104
|
+
rescue ::Redis::BaseError => e
|
105
|
+
raise Verdict::StorageError, "Redis error: #{e.message}"
|
106
|
+
end
|
107
|
+
|
108
|
+
def remove_assignment(experiment, subject_identifier)
|
109
|
+
redis.hdel(generate_experiment_key(experiment), subject_identifier)
|
110
|
+
end
|
111
|
+
|
112
|
+
def clear_experiment(experiment)
|
113
|
+
redis.del(generate_experiment_key(experiment))
|
114
|
+
redis.del(generate_experiment_start_timestamp_key(experiment))
|
115
|
+
end
|
116
|
+
|
117
|
+
def retrieve_start_timestamp(experiment)
|
118
|
+
if started_at = redis.get(generate_experiment_start_timestamp_key(experiment))
|
119
|
+
DateTime.parse(started_at).to_time
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def store_start_timestamp(experiment, timestamp)
|
124
|
+
redis.setnx(generate_experiment_start_timestamp_key(experiment), timestamp.to_s)
|
125
|
+
end
|
126
|
+
|
127
|
+
|
128
|
+
private
|
129
|
+
|
130
|
+
def generate_experiment_key(experiment)
|
131
|
+
"#{@key_prefix}#{experiment.handle}"
|
132
|
+
end
|
133
|
+
|
134
|
+
def generate_experiment_start_timestamp_key(experiment)
|
135
|
+
"#{@key_prefix}#{experiment.handle}/started_at"
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
def require_env(key)
|
2
|
+
value = ENV[key.downcase].presence || ENV[key.upcase].presence
|
3
|
+
raise "Provide #{key} as environment variable" if value.blank?
|
4
|
+
value
|
5
|
+
end
|
6
|
+
|
7
|
+
namespace :experiments do
|
8
|
+
|
9
|
+
desc "List all defined experiments"
|
10
|
+
task :list => 'environment' do
|
11
|
+
length = Experiments.repository.keys.map(&:length).max
|
12
|
+
Experiments.repository.each do |_, experiment|
|
13
|
+
print "#{experiment.handle.ljust(length)} | "
|
14
|
+
print "Groups: #{experiment.groups.values.map(&:to_s).join(', ')}"
|
15
|
+
puts
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
desc "Looks up the assignment for a given experiment and subject"
|
20
|
+
task :lookup_assignment => 'environment' do
|
21
|
+
experiment = Experiments[require_env('experiment')] or raise "Experiment not found"
|
22
|
+
subject_identifier = require_env('subject')
|
23
|
+
assignment = experiment.lookup_assignment_for_identifier(subject_identifier)
|
24
|
+
if assignment.nil?
|
25
|
+
puts "Subject #{ENV['subject']} is not assigned to experiment #{experiment.handle} yet."
|
26
|
+
elsif assignment.qualified?
|
27
|
+
puts "Subject #{ENV['subject']} is assigned to group `#{assignment.group.handle}`"
|
28
|
+
else
|
29
|
+
puts "Subject #{ENV['subject']} is unqualified for experiment #{experiment.handle}"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
desc "Manually assign a subject to a given group in an experiment"
|
34
|
+
task :assign_manually => 'environment' do
|
35
|
+
experiment = Experiments[require_env('experiment')] or raise "Experiment not found"
|
36
|
+
group = experiment.group(require_env('group')) or raise "Group not found"
|
37
|
+
assignment = experiment.subject_assignment(require_env('subject'), group, false)
|
38
|
+
experiment.store_assignment(assignment)
|
39
|
+
end
|
40
|
+
|
41
|
+
desc "Disqualify a subject from an experiment"
|
42
|
+
task :disqualify => 'environment' do
|
43
|
+
experiment = Experiments[require_env('experiment')] or raise "Experiment not found"
|
44
|
+
assignment = experiment.subject_assignment(require_env('subject'), nil, false)
|
45
|
+
experiment.store_assignment(assignment)
|
46
|
+
end
|
47
|
+
|
48
|
+
desc "Removes the assignment for a subject so it will be reassigned to the experiment."
|
49
|
+
task :remove_assignment => 'environment' do
|
50
|
+
experiment = Experiments[require_env('experiment')] or raise "Experiment not found"
|
51
|
+
experiment.remove_subject_identifier(require_env('subject'))
|
52
|
+
end
|
53
|
+
|
54
|
+
desc "Runs the cleanup tasks for an experiment"
|
55
|
+
task :wrapup => 'environment' do
|
56
|
+
experiment = Experiments[require_env('experiment')] or raise "Experiment not found"
|
57
|
+
experiment.wrapup
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
class AssignmentTest < MiniTest::Unit::TestCase
|
5
|
+
|
6
|
+
def setup
|
7
|
+
@experiment = Verdict::Experiment.new('assignment test')
|
8
|
+
@group = Verdict::Group.new(@experiment, :control)
|
9
|
+
end
|
10
|
+
|
11
|
+
def test_basic_properties
|
12
|
+
assignment = Verdict::Assignment.new(@experiment, 'test_subject_id', @group, Time.now.utc)
|
13
|
+
assert_equal 'test_subject_id', assignment.subject_identifier
|
14
|
+
assert_equal @experiment, assignment.experiment
|
15
|
+
assert_equal @group, assignment.group
|
16
|
+
assert assignment.returning?
|
17
|
+
assert assignment.qualified?
|
18
|
+
assert_equal :control, assignment.to_sym
|
19
|
+
assert_equal 'control', assignment.handle
|
20
|
+
assert_kind_of Time, assignment.created_at
|
21
|
+
|
22
|
+
non_assignment = Verdict::Assignment.new(@experiment, 'test_subject_id', nil, nil)
|
23
|
+
assert_equal nil, non_assignment.group
|
24
|
+
assert !non_assignment.returning?
|
25
|
+
assert !non_assignment.qualified?
|
26
|
+
assert_equal nil, non_assignment.to_sym
|
27
|
+
assert_equal nil, non_assignment.handle
|
28
|
+
assert_kind_of Time, assignment.created_at
|
29
|
+
end
|
30
|
+
|
31
|
+
def test_subject_lookup
|
32
|
+
assignment = Verdict::Assignment.new(@experiment, 'test_subject_id', nil, Time.now.utc)
|
33
|
+
assert_raises(NotImplementedError) { assignment.subject }
|
34
|
+
|
35
|
+
@experiment.expects(:fetch_subject).with('test_subject_id').returns(subject = mock('subject'))
|
36
|
+
assignment = Verdict::Assignment.new(@experiment, 'test_subject_id', nil, Time.now.utc)
|
37
|
+
assert_equal subject, assignment.subject
|
38
|
+
end
|
39
|
+
|
40
|
+
def test_triple_equals
|
41
|
+
assignment = Verdict::Assignment.new(@experiment, 'test_subject_id', @group, Time.now.utc)
|
42
|
+
assert !(assignment === nil)
|
43
|
+
assert assignment === @group
|
44
|
+
assert assignment === 'control'
|
45
|
+
assert assignment === :control
|
46
|
+
|
47
|
+
non_assignment = Verdict::Assignment.new(@experiment, 'test_subject_id', nil, Time.now.utc)
|
48
|
+
assert non_assignment === nil
|
49
|
+
assert !(non_assignment === @group)
|
50
|
+
assert !(non_assignment === 'control')
|
51
|
+
assert !(non_assignment === :control)
|
52
|
+
end
|
53
|
+
|
54
|
+
def test_json_representation
|
55
|
+
assignment = Verdict::Assignment.new(@experiment, 'test_subject_id', @group, Time.new(2013, 1, 1, 0, 0, 0, '+00:00'))
|
56
|
+
json = JSON.parse(assignment.to_json)
|
57
|
+
|
58
|
+
assert_equal 'assignment test', json['experiment']
|
59
|
+
assert_equal 'test_subject_id', json['subject']
|
60
|
+
assert_equal true, json['qualified']
|
61
|
+
assert_equal true, json['returning']
|
62
|
+
assert_equal 'control', json['group']
|
63
|
+
assert_equal '2013-01-01T00:00:00Z', json['created_at']
|
64
|
+
|
65
|
+
Timecop.freeze(Time.new(2012, 1, 1, 0, 0, 0, '+00:00')) do
|
66
|
+
non_assignment = Verdict::Assignment.new(@experiment, 'test_subject_id', nil, nil)
|
67
|
+
json = JSON.parse(non_assignment.to_json)
|
68
|
+
assert_equal 'assignment test', json['experiment']
|
69
|
+
assert_equal 'test_subject_id', json['subject']
|
70
|
+
assert_equal false, json['qualified']
|
71
|
+
assert_equal false, json['returning']
|
72
|
+
assert_equal nil, json['group']
|
73
|
+
assert_equal '2012-01-01T00:00:00Z', json['created_at']
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
class ConversionTest < MiniTest::Unit::TestCase
|
5
|
+
|
6
|
+
def setup
|
7
|
+
@experiment = Verdict::Experiment.new('conversion test') do
|
8
|
+
groups { group :all, 100 }
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def test_subject_lookup
|
13
|
+
conversion = Verdict::Conversion.new(@experiment, 'test_subject_id', :test_goal)
|
14
|
+
assert_raises(NotImplementedError) { conversion.subject }
|
15
|
+
|
16
|
+
@experiment.expects(:fetch_subject).with('test_subject_id').returns(subject = mock('subject'))
|
17
|
+
conversion = Verdict::Conversion.new(@experiment, 'test_subject_id', :test_goal)
|
18
|
+
assert_equal subject, conversion.subject
|
19
|
+
end
|
20
|
+
|
21
|
+
def test_assignment_lookup
|
22
|
+
@experiment.subject_storage.expects(:retrieve_assignment).with(@experiment, 'test_subject_id')
|
23
|
+
conversion = Verdict::Conversion.new(@experiment, 'test_subject_id', :test_goal)
|
24
|
+
conversion.assignment
|
25
|
+
end
|
26
|
+
|
27
|
+
def test_json_representation
|
28
|
+
conversion = Verdict::Conversion.new(@experiment, 'test_subject_id', :test_goal, Time.new(2013, 1, 1, 4, 5, 6, '+00:00'))
|
29
|
+
json = JSON.parse(conversion.to_json)
|
30
|
+
|
31
|
+
assert_equal 'conversion test', json['experiment']
|
32
|
+
assert_equal 'test_subject_id', json['subject']
|
33
|
+
assert_equal 'test_goal', json['goal']
|
34
|
+
assert_equal 'test_goal', json['goal']
|
35
|
+
assert_equal '2013-01-01T04:05:06Z', json['created_at']
|
36
|
+
end
|
37
|
+
end
|