verdict 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,3 @@
1
+ module Verdict
2
+ VERSION = "0.1.0"
3
+ 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