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.
@@ -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