verdict 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|