verdict 0.2.0 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +1 -0
  3. data/Gemfile +4 -0
  4. data/README.md +56 -33
  5. data/lib/verdict.rb +2 -1
  6. data/lib/verdict/experiment.rb +15 -7
  7. data/lib/verdict/segmenters.rb +4 -0
  8. data/lib/verdict/segmenters/base_segmenter.rb +84 -0
  9. data/lib/verdict/segmenters/fixed_percentage_segmenter.rb +56 -0
  10. data/lib/verdict/segmenters/rollout_segmenter.rb +55 -0
  11. data/lib/verdict/segmenters/static_segmenter.rb +27 -0
  12. data/lib/verdict/storage.rb +3 -138
  13. data/lib/verdict/storage/memory_storage.rb +40 -0
  14. data/lib/verdict/storage/mock_storage.rb +38 -0
  15. data/lib/verdict/storage/redis_storage.rb +62 -0
  16. data/lib/verdict/tasks.rake +8 -8
  17. data/lib/verdict/version.rb +1 -1
  18. data/test/assignment_test.rb +4 -4
  19. data/test/conversion_test.rb +1 -1
  20. data/test/event_logger_test.rb +1 -1
  21. data/test/experiment_test.rb +12 -3
  22. data/test/experiments_repository_test.rb +1 -1
  23. data/test/group_test.rb +2 -2
  24. data/test/metadata_test.rb +1 -1
  25. data/test/{fixed_percentage_segmenter_test.rb → segmenters/fixed_percentage_segmenter_test.rb} +11 -11
  26. data/test/{rollout_segmenter_test.rb → segmenters/rollout_segmenter_test.rb} +2 -2
  27. data/test/{static_segmenter_test.rb → segmenters/static_segmenter_test.rb} +2 -2
  28. data/test/{memory_subject_storage_test.rb → storage/memory_subject_storage_test.rb} +1 -1
  29. data/test/{redis_subject_storage_test.rb → storage/redis_subject_storage_test.rb} +2 -2
  30. data/test/test_helper.rb +8 -0
  31. data/verdict.gemspec +6 -6
  32. metadata +24 -19
  33. data/lib/verdict/fixed_percentage_segmenter.rb +0 -52
  34. data/lib/verdict/rollout_segmenter.rb +0 -53
  35. data/lib/verdict/segmenter.rb +0 -87
  36. data/lib/verdict/static_segmenter.rb +0 -24
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: bc058be7df27d778fc5c3d4333c1570c64edccc3
4
- data.tar.gz: 7095d5c3a2e6408f29018ea2490f978debe05ed9
3
+ metadata.gz: 969beb7809f021e2f73e70128dd7565081824240
4
+ data.tar.gz: 1abbcd8a1c3b5d0776d3649049d8b34cada22cf9
5
5
  SHA512:
6
- metadata.gz: 0a74b192fd5d4a03a6524e9c8f88d761b8eb2e83e6eb33957f46d429d526d9506eaf9787ae6d3665a80424d077f32d1dbb72498b1c7a81bb6a5380d581662155
7
- data.tar.gz: c73434af18dcaf65c05689daf3686ddc42a9bd6334018be0d7369fd577742c92ad6630ca4b82d539ba7ddb3aaa6a379a0a0204aa35cb9375dac3e09bd14b7229
6
+ metadata.gz: ae0034fd9af434c7fec7be4dd0aabc427dca17c7990e150941251209323cbad98522d3057dd26abc6e1fffcb739e881d5cacc54c636b798cda322c1cdc0b056a
7
+ data.tar.gz: f1e8ef2ca4f3ddc3995b72b33b3fa3dd2259c4520c8e691362a33e1ccfab9ce42aff6bc336b7723c1505db6a86d3ce4bcc90df07376201fbea208486ab3ef4f0
@@ -3,6 +3,7 @@ script: bundle exec rake
3
3
  rvm:
4
4
  - 1.9.3
5
5
  - 2.0.0
6
+ - 2.1.0
6
7
  - ruby-head
7
8
  - rbx
8
9
  - jruby-19mode
data/Gemfile CHANGED
@@ -4,3 +4,7 @@ source 'https://rubygems.org'
4
4
  gemspec
5
5
  gem "rubysl", platform: :rbx
6
6
  gem "json", platform: :rbx
7
+
8
+ group :development do
9
+ gem "simplecov", platforms: [:mri, :jruby]
10
+ end
data/README.md CHANGED
@@ -1,12 +1,14 @@
1
- # Verdict [![Build Status](https://travis-ci.org/Shopify/verdict.png)](https://travis-ci.org/Shopify/verdict)
1
+ # Verdict
2
+
3
+ [![Build Status](https://travis-ci.org/Shopify/verdict.png)](https://travis-ci.org/Shopify/verdict)
4
+ [![Code Climate](https://codeclimate.com/github/Shopify/verdict.png)](https://codeclimate.com/github/Shopify/verdict)
2
5
 
3
6
  This library allows you to define and use experiments in your application.
4
7
 
5
- - This library can be used in any Ruby application, and comes with a `Railtie` to
6
- make integrating it with a Rails app easy.
7
- - This library only handles consistently assigning subjects to experiment groups,
8
- and storing/logging these assignments for analysis. It doesn't do any analysis
9
- of results. That should happen elsewhere, e.g. in a data warehouse environment.
8
+ - It can be used in any Ruby application, and comes with a `Railtie` to make integrating it with a Rails app easy.
9
+ - It handles consistently assigning subjects to experiment groups, and storing/logging these assignments for analysis.
10
+
11
+ __*This library doesn't do any analysis of results. That should happen elsewhere, e.g. in a data warehouse environment.*__
10
12
 
11
13
 
12
14
  ## Installation
@@ -15,12 +17,13 @@ Add this line to your application's Gemfile, and run `bundle install`:
15
17
 
16
18
  gem 'verdict'
17
19
 
20
+ If you're using Rails, the Railtie will handle setting the logger to `Rails.logger` and the experiments directory to `app/experiments`. It will also load the rake tasks for you (run `bundle exec rake -T | grep experiments:` for options).
21
+
18
22
  ## Usage
19
23
 
20
- This gem contains the `Verdict::Experiment` model used create the experiment instance,
21
- in order consistently modify application behaviour based on an object's unique key.
24
+ The `Verdict::Experiment` class is used to create an experiment, define control and experiment groups, and to qualify subjects.
22
25
 
23
- Define an experiment like so:
26
+ You define an experiment like so:
24
27
 
25
28
  ``` ruby
26
29
  Verdict::Experiment.define :my_experiment do
@@ -39,46 +42,66 @@ Verdict::Experiment.define :my_experiment do
39
42
  end
40
43
  ```
41
44
 
42
- Usually you want to place this in a file called **my_experiment.rb** in the
43
- **/app/experiments** folder. Also, usually you want to subclass `Verdict::Experiment`
44
- to set some default options for your app's environment, and call `define` on that class
45
- instead.
45
+ Usually you'll want to place this in a file called **my_experiment.rb** in the
46
+ **/app/experiments** folder.
47
+
48
+ _We recommend that you subclass `Verdict::Experiment` to set some default options for your app's environment, and call `define` on that class instead._
46
49
 
47
- Refer to the experiment elsewhere in your codebase like this:
50
+ ### Determining a Subject's Group
51
+
52
+ At the relevant point in your application, you can check the group that a particular subject belongs to using the `switch` method.
53
+
54
+ You'll need to pass along the subject (think User, Product or any other Model class) as well as any context to be used for qualifying the subject.
48
55
 
49
56
  ``` ruby
50
- context = { ... } # anything you want to pass along to the qualify block.
57
+ context = { ... } # anything you want to pass along to the qualify block.
51
58
  case Verdict['my experiment'].switch(shop, context)
52
59
  when :test
53
60
  # Handle test group
54
61
  when :control
55
62
  # Handle control group
56
- else
57
- # Handle unqualified subjects.
63
+ else
64
+ # Handle unqualified subjects.
58
65
  end
59
66
  ```
60
67
 
61
- ## Storage & logging
68
+ ## Storage
69
+
70
+ Verdict uses a very simple interface for storing experiment assignments. Out of the box, Verdict ships with storage providers for:
71
+
72
+ * Memory
73
+ * Redis
74
+
75
+ You can set up storage for your experiment by calling the `storage` method with
76
+ an object that responds to the following methods:
77
+
78
+ * `store_assignment(assignment)`
79
+ * `retrieve_assignment(experiment, subject_identifier)`
80
+ * `remove_assignment(experiment, subject_identifier)`
81
+ * `clear_experiment(experiment)`
82
+ * `retrieve_start_timestamp(experiment)`
83
+ * `store_start_timestamp(experiment, timestamp)`
84
+
85
+ Regarding the method signatures above, `experiment` is the Experiment instance, `subject_identifier` is a string that uniquely identifies the subject, and `assignment` is a `Verdict::Assignment` instance.
86
+
87
+ By default it will use `subject.id.to_s` as `subject_identifier`, but you can change that by overriding `def subject_identifier(subject)` on the experiment.
88
+
89
+ Storage providers simply store subject assignments and require quick lookups of subject identifiers. They allow for complex (high CPU) assignments, and for assignments that might not always put the same subject in the same group by storing the assignment for later use.
90
+
91
+ Storage providers are intended for operational use and should not be used for data analysis. For data analysis, you should use the logger.
92
+
93
+ For more details about these methods, check out the source code for [Verdict::Storage::MockStorage](lib/verdict/storage/mock.rb)
94
+
95
+ ## Logging
62
96
 
63
- The library uses a basic interface to store experiment assignments. Except for
64
- a development-only memory store, it doesn't include any storage models.
97
+ Every assignment will be logged to `Verdict.logger`. For rails apps, this logger will be automatically set to `Rails.logger` so experiment assignments will show up in your Rails log.
65
98
 
66
- You can set up storage by calling the `storage` method of your experiment, with
67
- an object that responds to the following tho methods:
99
+ You can override the logging by overriding the `def log_assignment(assignment)` method on the experiment.
68
100
 
69
- - `def retrieve_assignment(experiment, subject_identifier)`
70
- - `def store_assignment(assignment)`
101
+ Logging (as opposed to storage) should be used for data analysis. The logger requires a write-only / forward-only stream to write to, e.g. a log file, Kafka, or an insert-only database table.
71
102
 
72
- In which `experiment` is the Experiment instance, `subject_identifier` is a
73
- string that uniquely identifies the subject, and `assignment` is an
74
- `Verdict::Assignment` instance. By default it will use `subject.id.to_s` as
75
- `subject_identifier`, but you can change that by overriding the
76
- `def subject_identifier(subject)` method on the experiment.
103
+ It's possible to run an experiment without defining any storage, though this comes with several drawbacks. Logging on the other hand is required in order to analyze the results.
77
104
 
78
- The library will also log every assignment to `Verdict.logger`. The Railtie
79
- sets `Verdict.logger` to `Rails.logger`, so experiment assignments will show
80
- up in your Rails log. You can override the logging by overriding the
81
- `def log_assignment(assignment)` method on the experiment.
82
105
 
83
106
  ## Contributing
84
107
 
@@ -1,4 +1,5 @@
1
1
  require 'logger'
2
+ require 'digest/md5'
2
3
 
3
4
  module Verdict
4
5
  extend self
@@ -46,7 +47,7 @@ require "verdict/experiment"
46
47
  require "verdict/group"
47
48
  require "verdict/assignment"
48
49
  require "verdict/conversion"
49
- require "verdict/segmenter"
50
+ require "verdict/segmenters"
50
51
  require "verdict/storage"
51
52
  require "verdict/event_logger"
52
53
 
@@ -16,7 +16,7 @@ class Verdict::Experiment
16
16
  options = default_options.merge(options)
17
17
  @qualifier = options[:qualifier]
18
18
  @event_logger = options[:event_logger] || Verdict::EventLogger.new(Verdict.default_logger)
19
- @subject_storage = options[:storage] || Verdict::Storage::MemoryStorage.new
19
+ @subject_storage = storage(options[:storage] || :memory)
20
20
  @store_unqualified = options[:store_unqualified]
21
21
  @segmenter = options[:segmenter]
22
22
  @subject_type = options[:subject_type]
@@ -25,6 +25,7 @@ class Verdict::Experiment
25
25
  instance_eval(&block) if block_given?
26
26
  end
27
27
 
28
+
28
29
  def subject_type(type = nil)
29
30
  return @subject_type if type.nil?
30
31
  @subject_type = type
@@ -38,7 +39,7 @@ class Verdict::Experiment
38
39
  segmenter.groups[handle.to_s]
39
40
  end
40
41
 
41
- def groups(segmenter_class = Verdict::FixedPercentageSegmenter, &block)
42
+ def groups(segmenter_class = Verdict::Segmenters::FixedPercentageSegmenter, &block)
42
43
  return segmenter.groups unless block_given?
43
44
  @segmenter ||= segmenter_class.new(self)
44
45
  @segmenter.instance_eval(&block)
@@ -47,7 +48,7 @@ class Verdict::Experiment
47
48
  end
48
49
 
49
50
  def rollout_percentage(percentage, rollout_group_name = :enabled)
50
- groups(Verdict::RolloutSegmenter) do
51
+ groups(Verdict::Segmenters::RolloutSegmenter) do
51
52
  group rollout_group_name, percentage
52
53
  end
53
54
  end
@@ -56,9 +57,16 @@ class Verdict::Experiment
56
57
  @qualifier = block
57
58
  end
58
59
 
59
- def storage(subject_storage, options = {})
60
+ def storage(subject_storage = nil, options = {})
61
+ return @subject_storage if subject_storage.nil?
62
+
60
63
  @store_unqualified = options[:store_unqualified] if options.has_key?(:store_unqualified)
61
- @subject_storage = subject_storage
64
+ @subject_storage = case subject_storage
65
+ when :memory; Verdict::Storage::MemoryStorage.new
66
+ when :none; Verdict::Storage::MockStorage.new
67
+ when Class; subject_storage.new
68
+ else subject_storage
69
+ end
62
70
  end
63
71
 
64
72
  def segmenter
@@ -184,7 +192,7 @@ class Verdict::Experiment
184
192
 
185
193
  def to_json(options = {})
186
194
  as_json(options).to_json
187
- end
195
+ end
188
196
 
189
197
  def fetch_subject(subject_identifier)
190
198
  raise NotImplementedError, "Fetching subjects based in identifier is not implemented for eperiment @{handle.inspect}."
@@ -221,7 +229,7 @@ class Verdict::Experiment
221
229
  return previous_assignment unless previous_assignment.nil?
222
230
  group = segmenter.assign(subject_identifier, subject, context)
223
231
  subject_assignment(subject_identifier, group, nil, group.nil?)
224
- else
232
+ else
225
233
  subject_assignment(subject_identifier, nil, nil)
226
234
  end
227
235
  end
@@ -0,0 +1,4 @@
1
+ require 'verdict/segmenters/base_segmenter'
2
+ require 'verdict/segmenters/static_segmenter'
3
+ require 'verdict/segmenters/fixed_percentage_segmenter'
4
+ require 'verdict/segmenters/rollout_segmenter'
@@ -0,0 +1,84 @@
1
+ module Verdict
2
+ module Segmenters
3
+ # Base class of all segmenters.
4
+ #
5
+ # The segmenter is responsible for assigning subjects to groups. You can
6
+ # implement any assignment strategy you like by subclassing this class and
7
+ # using it in your experiment.
8
+ #
9
+ # - You should implement the register_group method for the experiment definition DSL
10
+ # to make the system aware of the groups that the segmenter could return.
11
+ # - The verify! method is called after all the groups have been defined, so it can
12
+ # detect internal inconsistencies in the group definitions.
13
+ # - The assign method is where your assignment magic lives.
14
+ class BaseSegmenter
15
+ # The experiment to which this segmenter is associated
16
+ attr_reader :experiment
17
+
18
+ # A hash of the groups that are defined in this experiment, indexed by their
19
+ # handle. The assign method should return one of the groups in this hash
20
+ attr_reader :groups
21
+
22
+ def initialize(experiment)
23
+ @experiment = experiment
24
+ @groups = {}
25
+ end
26
+
27
+ # DSL method to register a group. It calls the register_group method of the
28
+ # segmenter implementation
29
+ def group(handle, *args, &block)
30
+ group = register_group(handle, *args)
31
+ @groups[group.handle] = group
32
+ group.instance_eval(&block) if block_given?
33
+ end
34
+
35
+ # The group method is called from the experiment definition DSL.
36
+ # It should register a new group to the segmenter, with the given handle.
37
+ #
38
+ # - The handle parameter is a symbol that uniquely identifies the group within
39
+ # this experiment.
40
+ # - The return value of this method should be a Verdict::Group instance.
41
+ def register_group(handle, *args)
42
+ raise NotImplementedError
43
+ end
44
+
45
+ # The verify! method is called after all the groups have been defined in the
46
+ # experiment definition DSL. You can run any consistency checks in this method,
47
+ # and if anything is off, you can raise a Verdict::SegmentationError to
48
+ # signify the problem.
49
+ def verify!
50
+ # noop by default
51
+ end
52
+
53
+ # The assign method is called to assign a subject to one of the groups that have been defined
54
+ # in the segmenter implementation.
55
+ #
56
+ # - The identifier parameter is a string that uniquely identifies the subject.
57
+ # - The subject paramater is the subject instance that was passed to the framework,
58
+ # when the application code calls Experiment#assign or Experiment#switch.
59
+ # - The context parameter is an object that was passed to the framework, you can use this
60
+ # object any way you like in your segmenting logic.
61
+ #
62
+ # This method should return the Verdict::Group instance to which the subject should be assigned.
63
+ # This instance should be one of the group instance that was registered in the definition DSL.
64
+ def assign(identifier, subject, context)
65
+ raise NotImplementedError
66
+ end
67
+
68
+
69
+ # This method is called whenever a subjects converts to a goal, i.e., when Experiment#convert
70
+ # is called. You can use this to implement a feedback loop in your segmenter.
71
+ #
72
+ # - The identifier parameter is a string that uniquely identifies the subject.
73
+ # - The subject paramater is the subject instance that was passed to the framework,
74
+ # when the application code calls Experiment#assign or Experiment#switch.
75
+ # - The conversion parameter is a Verdict::Conversion instance that describes what
76
+ # goal the subject converted to.
77
+ #
78
+ # The return value of this method is not used.
79
+ def conversion_feedback(identifier, subject, conversion)
80
+ # noop by default
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,56 @@
1
+ module Verdict
2
+ module Segmenters
3
+ class FixedPercentageSegmenter < BaseSegmenter
4
+
5
+ def initialize(experiment)
6
+ super
7
+ @total_percentage_segmented = 0
8
+ end
9
+
10
+ def verify!
11
+ raise Verdict::SegmentationError, "Should segment exactly 100% of the cases, but segments add up to #{@total_percentage_segmented}%." if @total_percentage_segmented != 100
12
+ end
13
+
14
+ def register_group(handle, size)
15
+ percentage = size.kind_of?(Hash) && size[:percentage] ? size[:percentage] : size
16
+ n = case percentage
17
+ when :rest; 100 - @total_percentage_segmented
18
+ when :half; 50
19
+ when Integer; percentage
20
+ else Integer(percentage)
21
+ end
22
+
23
+ group = Group.new(experiment, handle, @total_percentage_segmented ... (@total_percentage_segmented + n))
24
+ @total_percentage_segmented += n
25
+ return group
26
+ end
27
+
28
+ def assign(identifier, subject, context)
29
+ percentile = Digest::MD5.hexdigest("#{@experiment.handle}#{identifier}").to_i(16) % 100
30
+ groups.values.find { |group| group.percentile_range.include?(percentile) }
31
+ end
32
+
33
+ class Group < Verdict::Group
34
+
35
+ attr_reader :percentile_range
36
+
37
+ def initialize(experiment, handle, percentile_range)
38
+ super(experiment, handle)
39
+ @percentile_range = percentile_range
40
+ end
41
+
42
+ def percentage_size
43
+ percentile_range.end - percentile_range.begin
44
+ end
45
+
46
+ def to_s
47
+ "#{handle} (#{percentage_size}%)"
48
+ end
49
+
50
+ def as_json(options = {})
51
+ super(options).merge(percentage: percentage_size)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,55 @@
1
+ module Verdict
2
+ module Segmenters
3
+ class RolloutSegmenter < BaseSegmenter
4
+
5
+ def initialize(experiment)
6
+ super
7
+ @total_percentage_segmented = 0
8
+ end
9
+
10
+ def verify!
11
+ raise Verdict::SegmentationError, "Should segment less than 100% of the cases, but segments add up to #{@total_percentage_segmented}%." if @total_percentage_segmented >= 100
12
+ end
13
+
14
+ def register_group(handle, size)
15
+ percentage = size.kind_of?(Hash) && size[:percentage] ? size[:percentage] : size
16
+ n = case percentage
17
+ when :rest; 100 - @total_percentage_segmented
18
+ when :half; 50
19
+ when Integer; percentage
20
+ else Integer(percentage)
21
+ end
22
+
23
+ group = Group.new(experiment, handle, @total_percentage_segmented ... (@total_percentage_segmented + n))
24
+ @total_percentage_segmented += n
25
+ return group
26
+ end
27
+
28
+ def assign(identifier, subject, context)
29
+ percentile = Digest::MD5.hexdigest("#{@experiment.handle}#{identifier}").to_i(16) % 100
30
+ groups.values.find { |group| group.percentile_range.include?(percentile) }
31
+ end
32
+
33
+ class Group < Verdict::Group
34
+ attr_reader :percentile_range
35
+
36
+ def initialize(experiment, handle, percentile_range)
37
+ super(experiment, handle)
38
+ @percentile_range = percentile_range
39
+ end
40
+
41
+ def percentage_size
42
+ percentile_range.end - percentile_range.begin
43
+ end
44
+
45
+ def to_s
46
+ "#{handle} (#{percentage_size}%)"
47
+ end
48
+
49
+ def as_json(options = {})
50
+ super(options).merge(percentage: percentage_size)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,27 @@
1
+ module Verdict
2
+ module Segmenters
3
+ class StaticSegmenter < BaseSegmenter
4
+
5
+ def register_group(handle, subject_identifiers)
6
+ Group.new(experiment, handle, subject_identifiers)
7
+ end
8
+
9
+ def assign(identifier, subject, context)
10
+ groups.values.find { |group| group.subject_identifiers.include?(identifier) }
11
+ end
12
+
13
+ class Group < Verdict::Group
14
+ attr_reader :subject_identifiers
15
+
16
+ def initialize(experiment, handle, subject_identifiers)
17
+ super(experiment, handle)
18
+ @subject_identifiers = subject_identifiers
19
+ end
20
+
21
+ def as_json(options = {})
22
+ super(options).merge(subject_identifiers: subject_identifiers)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end