vinted-ab 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +11 -0
  3. data/README.md +70 -66
  4. data/ab.gemspec +1 -0
  5. data/config.json +74 -0
  6. data/lib/ab/assigned_test.rb +4 -4
  7. data/lib/ab/null_test.rb +0 -1
  8. data/lib/ab/test.rb +14 -3
  9. data/lib/ab/tests.rb +13 -14
  10. data/lib/ab/version.rb +1 -1
  11. data/spec/assigned_test_spec.rb +21 -27
  12. data/spec/examples/all_buckets/input.json +22 -0
  13. data/spec/examples/all_buckets/output.json +7 -0
  14. data/spec/examples/already_finished/input.json +20 -0
  15. data/spec/examples/already_finished/output.json +6 -0
  16. data/spec/examples/big_weights/input.json +22 -0
  17. data/spec/examples/big_weights/output.json +7 -0
  18. data/spec/examples/explicit_times/input.json +20 -0
  19. data/spec/examples/explicit_times/output.json +6 -0
  20. data/spec/examples/few_buckets/input.json +22 -0
  21. data/spec/examples/few_buckets/output.json +8 -0
  22. data/spec/examples/has_not_started/input.json +19 -0
  23. data/spec/examples/has_not_started/output.json +6 -0
  24. data/spec/examples/multiple_tests/input.json +30 -0
  25. data/spec/examples/multiple_tests/output.json +6 -0
  26. data/spec/examples/multiple_variants/input.json +30 -0
  27. data/spec/examples/multiple_variants/output.json +9 -0
  28. data/spec/examples/no_buckets/input.json +17 -0
  29. data/spec/examples/no_buckets/output.json +6 -0
  30. data/spec/examples/no_variants/input.json +13 -0
  31. data/spec/examples/no_variants/output.json +6 -0
  32. data/spec/examples/zero_buckets/input.json +18 -0
  33. data/spec/examples/zero_buckets/output.json +6 -0
  34. data/spec/examples/zero_weight/input.json +22 -0
  35. data/spec/examples/zero_weight/output.json +6 -0
  36. data/spec/integration_spec.rb +28 -0
  37. data/spec/null_test_spec.rb +4 -2
  38. data/spec/test_spec.rb +19 -2
  39. data/spec/tests_spec.rb +47 -21
  40. metadata +49 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 45ab4518594015ccbca8ebd4d441f835e103e22c
4
- data.tar.gz: 80fa73e5144373f439208f7773b82c76c4949de4
3
+ metadata.gz: 73b2901ba92e371c5f79d864db755df119cadd6a
4
+ data.tar.gz: 14bfef36209d2fd22a70ec59d7741812a18c67a7
5
5
  SHA512:
6
- metadata.gz: 467ebec6807d536330430ebd111cff5fcb7d43935b43fa01866d370d61a5db9afa934b662836d5c025cd44395737270a1d029209ed4b5a8a01c58b953530f58c
7
- data.tar.gz: a6a561c9a611031fd09d75da86ad36d6b3714fed4980e46cdae5a863aa1c7c45a25c48135134a5e87a1f183dd78e847c07cb0f45def7bf2495ad136c6ef918fa
6
+ metadata.gz: 8ae98d8cd54d30db32c7a54aee1377a9b782f4ca9d38c7e5d00cfea6f63f7e616faba5309601698eea4045ba6497612f12d4a47ff3a9f90cdfefbe45d1a8df62
7
+ data.tar.gz: 7c709e4b04db27be43ddc6b03fa37aca36d6e31dab8625abe153b2b68305c52ec8a8bbb138384295603ecccafec54eef421b8c65ee10c5937576761cc7fe2e61
data/.rubocop.yml ADDED
@@ -0,0 +1,11 @@
1
+ LineLength:
2
+ Max: 99
3
+
4
+ Documentation:
5
+ Enabled: false
6
+
7
+ SignalException:
8
+ EnforcedStyle: only_raise
9
+
10
+ TrailingComma:
11
+ Enabled: false
data/README.md CHANGED
@@ -1,8 +1,48 @@
1
1
  # ab
2
2
 
3
+ [![Code Climate](https://codeclimate.com/github/vinted/ab.png)](https://codeclimate.com/github/vinted/ab)
4
+ [![Gem Version](https://badge.fury.io/rb/vinted-ab.png)](http://badge.fury.io/rb/vinted-ab)
5
+ [![Dependency Status](https://gemnasium.com/vinted/ab.png)](https://gemnasium.com/vinted/ab)
6
+
7
+ vinted-ab is used to determine whether an identifier belongs to a particular ab test and which variant of that ab test. Identifiers will usually represent users, but other scenario are possible. There are two parts to that: [Configuration](#configuration) and [Algorithm](#algorithm).
8
+
9
+ High-level description: Identifiers are divided into some number of buckets, using hashing. Before a test is started, buckets are chosen for that test. That gives the ability to pick the needed level of isolation. Each test also has a seed, which is used to randomise how users are divided among test variants.
10
+
11
+ ![users](https://cloud.githubusercontent.com/assets/54526/2971326/0535267a-db69-11e3-9878-e2b6a5d5505d.png)
12
+
13
+ ## Usage
14
+
15
+ ```ruby
16
+ ab = Ab::Tests.new(configuration, identifier)
17
+
18
+ # defining callbacks
19
+ Ab::Tests.before_picking_variant { |test| puts "picking variant for #{test}" }
20
+ Ab::Tests.after_picking_variant { |test, variant| puts "#{variant_name}" }
21
+
22
+ # ab.test never returns nil
23
+ # but if you don't belong to any of the buckets, variant will be nil
24
+ case ab.test.variant
25
+ when 'red_button'
26
+ red_button
27
+ when 'green_button'
28
+ green_button
29
+ else
30
+ blue_button
31
+ end
32
+
33
+ # calls #variant underneath, results of that call are cached
34
+ puts 'red button' if ab.test.red_button?
35
+ ```
36
+
3
37
  ## Configuration
4
38
 
5
- For this lib to work, it requires a configuration json, which looks like this:
39
+ Configuration is expected to be in JSON, for which you can find the Schema [here](https://github.com/vinted/ab/blob/master/config.json). The provided schema is compatible with JSON Schema Draft 3. If you'd like to validate your JSON against this schema, in Ruby, you can do it using `json-schema` gem:
40
+
41
+ ```
42
+ JSON::Validator.validate('/path/to/schema/config.json', json, version: :draft3)
43
+ ```
44
+
45
+ An example config:
6
46
 
7
47
  ```json
8
48
  {
@@ -15,13 +55,7 @@ For this lib to work, it requires a configuration json, which looks like this:
15
55
  "start_at": "2014-05-21T11:06:30+03:00",
16
56
  "end_at": "2014-05-28T11:06:30+03:00",
17
57
  "seed": "aaaa1111",
18
- "buckets": [
19
- 1,
20
- 2,
21
- 3,
22
- 4,
23
- 5
24
- ],
58
+ "buckets": [1, 2, 3, 4, 5],
25
59
  "variants": [
26
60
  {
27
61
  "name": "green_button",
@@ -37,71 +71,41 @@ For this lib to work, it requires a configuration json, which looks like this:
37
71
  }
38
72
  ]
39
73
  },
40
- {
41
- "id": 44,
42
- "name": "red_shirts",
43
- "start_at": "2014-05-24T11:09:30+03:00",
44
- "end_at": "2099-05-24T11:09:30+03:00",
45
- "seed": "bbbb4444",
46
- "buckets": [
47
- 4,
48
- 5,
49
- 6,
50
- 7
51
- ],
52
- "variants": [
53
- {
54
- "name": "red_shirt",
55
- "chance_weight": 1
56
- },
57
- {
58
- "name": "control",
59
- "chance_weight": 1
60
- }
61
- ]
62
- },
63
- {
64
- "id": 47,
65
- "name": "feed",
66
- "start_at": "1999-03-31T00:00:00+03:00",
67
- "end_at": "2099-03-31T00:00:00+03:00",
68
- "seed": "cccc8888",
69
- "buckets": "all",
70
- "variants": [
71
- {
72
- "name": "enabled",
73
- "chance_weight": 1
74
- }
75
- ]
76
- },
77
74
  ]
78
75
  }
79
76
  ```
80
77
 
81
- ## Usage
78
+ Short explanation for a couple of config parameters:
82
79
 
83
- ```ruby
84
- configuration = retrieve_from_svc_abs
85
- ab = Ab::Tests.new(configuration, user_id)
80
+ `salt`: used to salt every identifier, before determining to which bucket that identifier belongs.
86
81
 
87
- # defining callbacks
88
- Ab::Tests.before_picking_variant { |test| puts 'magic' }
89
- Ab::Tests.after_picking_variant { |test, variant| puts "#{variant_name}" }
82
+ `bucket_count`: the total number of buckets.
90
83
 
91
- # ab.experiment never returns nil
92
- # but if you don't belong to any of the buckets, variant will be nil
93
- case ab.experiment.variant
94
- when 'red_button'
95
- red_button
96
- when 'green_button'
97
- green_button
98
- else
99
- blue_button
100
- end
84
+ `all_buckets`: optional boolean which tells that all buckets are used in this test. Checking `buckets` is not required in that case.
85
+
86
+ `ab_tests.start_at`: the start date time for ab test, in ISO 8601 format. Is not required, in which case, test has already started.
87
+
88
+ `ab_tests.end_at`: the end date time for ab test, in ISO 8601 format. Is not required, in which case, there's no predetermined date when test will end.
89
+
90
+ `ab_tests.buckets`: which buckets should be used for this ab test, represented as bucket ids. If the total number of buckets is 1000, values of this arrays are expected to be in 1..1000 range.
91
+
92
+ `ab_tests.variants`: tests can have multiple variants, each with a name and a weight.
93
+
94
+ More examples can be found in [spec/examples](https://github.com/vinted/ab/tree/master/spec/examples). Those examples are part of the test suite, which is run using [this code](https://github.com/vinted/ab/blob/master/spec/integration_spec.rb). We strongly recommend using those examples if you're reimplementing this library in another language.
95
+
96
+ ## Algorithm
97
+
98
+ Most of the logic, is in `AssignedTest` class, which can be used as an [example implementation](https://github.com/vinted/ab/blob/master/lib/ab/assigned_test.rb).
99
+
100
+ Here's some procedural pseudo code to serve as a reference:
101
+
102
+ ```pseudo
103
+ bucket_id = SHA256(salt + identifier.to_string).to_integer % bucket_count
101
104
 
102
- # calls #variant underneath
103
- # #variant caches results, so meta tracking events would not be sent multiple times
104
- puts 'magic' if ab.experiment.red_button?
105
+ return if not (test.all_buckets? or test.buckets.include?(bucket_id))
106
+ return if not DateTime.now.between?(test.start_at, test.end_at)
105
107
 
106
- render_feed if ab.feed.enabled?
108
+ chance_weight_sum = chance_weight_sum > 0 ? test.chance_weight_sum : 1
109
+ weight_id = SHA256(test.seed + identifier.to_string).to_integer % chance_weight_sum
110
+ test.variants.find { |variant| variant.accumulated_chance_weight > weight_id }
107
111
  ```
data/ab.gemspec CHANGED
@@ -23,4 +23,5 @@ Gem::Specification.new do |spec|
23
23
  spec.add_development_dependency 'bundler', '~> 1.3'
24
24
  spec.add_development_dependency 'rake', '~> 10.1', '>= 10.1.0'
25
25
  spec.add_development_dependency 'rspec', '~> 2.14', '>= 2.14.0'
26
+ spec.add_development_dependency 'json-schema', '~> 2.2.2', '>= 2.2.2'
26
27
  end
data/config.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "type": "object",
3
+ "required": true,
4
+ "properties": {
5
+ "bucket_count": {
6
+ "type": "number",
7
+ "required": true
8
+ },
9
+ "salt": {
10
+ "type": "string",
11
+ "required": true
12
+ },
13
+ "ab_tests": {
14
+ "type": "array",
15
+ "required": false,
16
+ "items": [
17
+ {
18
+ "type": "object",
19
+ "properties": {
20
+ "all_buckets": {
21
+ "type": "boolean",
22
+ "required": false
23
+ },
24
+ "buckets": {
25
+ "type": "array",
26
+ "required": false,
27
+ "items": {
28
+ "type": "number",
29
+ "required": false
30
+ }
31
+ },
32
+ "start_at": {
33
+ "type": "string",
34
+ "required": false
35
+ },
36
+ "end_at": {
37
+ "type": "string",
38
+ "required": false
39
+ },
40
+ "id": {
41
+ "type": "number",
42
+ "required": true
43
+ },
44
+ "name": {
45
+ "type": "string",
46
+ "required": true
47
+ },
48
+ "seed": {
49
+ "type": "string",
50
+ "required": true
51
+ },
52
+ "variants": {
53
+ "type": "array",
54
+ "required": true,
55
+ "items": {
56
+ "type": "object",
57
+ "properties": {
58
+ "chance_weight": {
59
+ "type": "number",
60
+ "required": true
61
+ },
62
+ "name": {
63
+ "type": "string",
64
+ "required": true
65
+ }
66
+ }
67
+ }
68
+ }
69
+ }
70
+ }
71
+ ]
72
+ }
73
+ }
74
+ }
@@ -27,16 +27,16 @@ module Ab
27
27
  private
28
28
 
29
29
  def part_of_test?
30
- @test.buckets == 'all' || @test.buckets.include?(bucket_id)
30
+ @test.all_buckets? ||
31
+ @test.buckets && @test.buckets.include?(bucket_id)
31
32
  end
32
33
 
33
34
  def bucket_id
34
- @bucket_id ||= digest(@test.salt + @id.to_s) % @test.bucket_count
35
+ @bucket_id ||= digest("#{@test.salt}#{@id}") % @test.bucket_count
35
36
  end
36
37
 
37
38
  def running?
38
- now = DateTime.now
39
- now.between?(@test.start_at, @test.end_at)
39
+ DateTime.now.between?(@test.start_at, @test.end_at)
40
40
  end
41
41
 
42
42
  def weight_id
data/lib/ab/null_test.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  class NullTest
2
2
  def variant
3
- nil
4
3
  end
5
4
 
6
5
  def method_missing(meth, *args, &block)
data/lib/ab/test.rb CHANGED
@@ -4,6 +4,10 @@ module Ab
4
4
  hash['buckets']
5
5
  end
6
6
 
7
+ def all_buckets?
8
+ hash['all_buckets']
9
+ end
10
+
7
11
  def name
8
12
  hash['name']
9
13
  end
@@ -12,7 +16,7 @@ module Ab
12
16
  @variants ||= begin
13
17
  accumulated = 0
14
18
  hash['variants'].map do |variant_hash|
15
- Variant.new(variant_hash, accumulated += variant_hash['chance_weight'] )
19
+ Variant.new(variant_hash, accumulated += variant_hash['chance_weight'])
16
20
  end
17
21
  end
18
22
  end
@@ -22,15 +26,22 @@ module Ab
22
26
  end
23
27
 
24
28
  def start_at
25
- @start_at ||= hash['start_at'].nil? ? DateTime.new(0) : DateTime.parse(hash['start_at'])
29
+ @start_at ||= parse_time('start_at', 0)
26
30
  end
27
31
 
28
32
  def end_at
29
- @end_at ||= hash['end_at'].nil? ? DateTime.new(3000) : DateTime.parse(hash['end_at'])
33
+ @end_at ||= parse_time('end_at', 3000)
30
34
  end
31
35
 
32
36
  def weight_sum
33
37
  variants.map(&:chance_weight).inject(:+)
34
38
  end
39
+
40
+ private
41
+
42
+ def parse_time(name, default)
43
+ value = hash[name]
44
+ value.nil? ? DateTime.new(default) : DateTime.parse(value)
45
+ end
35
46
  end
36
47
  end
data/lib/ab/tests.rb CHANGED
@@ -10,24 +10,23 @@ module Ab
10
10
  Ab::Tests.run_hook :after_picking_variant, test, variant
11
11
  end
12
12
 
13
- def initialize(config, id)
14
- @assigned_tests ||= {}
15
-
16
- (config['ab_tests'] || []).each do |test|
17
- name = test['name']
18
- @assigned_tests[name] = nil
19
- define_singleton_method(name) do
20
- test = Test.new(test, config['salt'], config['bucket_count'])
21
- @assigned_tests[name] ||= AssignedTest.new(test, id)
22
- end
13
+ def initialize(json, id)
14
+ config = json.is_a?(Hash) ? json : JSON.parse(json)
15
+
16
+ salt = config['salt']
17
+ bucket_count = config['bucket_count']
18
+
19
+ tests = (config['ab_tests'] || []).map { |test| Test.new(test, salt, bucket_count) }
20
+
21
+ @assigned_tests = tests.map do |test|
22
+ assigned_test = AssignedTest.new(test, id)
23
+ define_singleton_method(test.name) { assigned_test }
24
+ [test.name, assigned_test]
23
25
  end
24
26
  end
25
27
 
26
28
  def all
27
- result = @assigned_tests.keys.map do |name|
28
- [name, send(name).variant]
29
- end
30
- Hash[result]
29
+ Hash[@assigned_tests.map { |name, assigned_test| [name, assigned_test.variant] }]
31
30
  end
32
31
 
33
32
  def method_missing(meth, *args, &block)
data/lib/ab/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Ab
2
- VERSION = '0.1.0'
2
+ VERSION = '0.2.0'
3
3
  end
@@ -1,52 +1,46 @@
1
1
  require 'spec_helper'
2
+ require 'ostruct'
2
3
 
3
4
  module Ab
4
5
  describe AssignedTest do
5
6
  let(:assigned_test) { AssignedTest.new(test, id) }
6
- let(:test) { Test.new(hash, 'e131bfcfcab06c65d633d0266c7e62a4918', 1000) }
7
- let(:hash) {
8
- {
9
- 'name' => name,
10
- 'start_at' => start_at,
11
- 'end_at' => end_at,
12
- 'buckets' => buckets,
13
- 'seed' => seed,
14
- 'variants' => variants
15
- }
16
- }
7
+ let(:test) do
8
+ OpenStruct.new(name: name, start_at: start_at, end_at: end_at,
9
+ buckets: buckets, seed: 'cccc8888', variants: variants,
10
+ weight_sum: 2, salt: 'e131bfcfcab06c65d633d0266c7e62a4918', bucket_count: 1000)
11
+ end
17
12
  let(:id) { 1 }
18
13
  let(:name) { 'feed' }
19
- let(:start_at) { DateTime.now.prev_year.to_s }
20
- let(:end_at) { DateTime.now.next_year.to_s }
21
- let(:seed) { 'cccc8888' }
22
- let(:buckets) { 'all' }
14
+ let(:start_at) { DateTime.now.prev_year }
15
+ let(:end_at) { DateTime.now.next_year }
16
+ let(:buckets) { 1..1000 }
23
17
  let(:thousand_variants) { 1.upto(1000).map { |i| AssignedTest.new(test, i).variant } }
24
18
 
25
19
  describe '#variant' do
26
20
  subject { assigned_test.variant }
27
21
 
28
22
  context 'single variant' do
29
- let(:variants) { [{ 'name' => 'enabled', 'chance_weight' => chance_weight }] }
23
+ let(:variants) { [OpenStruct.new(name: 'enabled', accumulated_chance_weight: accumulated_chance_weight)] }
30
24
 
31
25
  context 'that is turned off' do
32
- let(:chance_weight) { 0 }
26
+ let(:accumulated_chance_weight) { 0 }
33
27
  it { should be_nil }
34
28
  end
35
29
 
36
- context 'that is turned on with 1' do
37
- let(:chance_weight) { 1 }
30
+ context 'that is turned on with 2' do
31
+ let(:accumulated_chance_weight) { 2 }
38
32
  it { should == 'enabled' }
39
33
  end
40
34
 
41
35
  context 'that is turned on with 111' do
42
- let(:chance_weight) { 111 }
36
+ let(:accumulated_chance_weight) { 111 }
43
37
  it { should == 'enabled' }
44
38
  end
45
39
  end
46
40
 
47
41
  context 'single variant with buckets' do
48
42
  let(:buckets) { (1..100) }
49
- let(:variants) { [{ 'name' => 'enabled', 'chance_weight' => 1 }] }
43
+ let(:variants) { [OpenStruct.new(name: 'enabled', accumulated_chance_weight: 2)] }
50
44
 
51
45
  specify 'half the ids fall under one, other under other' do
52
46
  enabled = thousand_variants.select { |variant| variant == 'enabled' }
@@ -57,25 +51,25 @@ module Ab
57
51
  context 'test that has not started yet' do
58
52
  let(:start_at) { DateTime.now.next_year.to_s }
59
53
  let(:buckets) { [1, 2, 3] }
60
- let(:variants) { [{ 'name' => 'enabled', 'chance_weight' => 1 }] }
54
+ let(:variants) { [OpenStruct.new(name: 'enabled', accumulated_chance_weight: 2)] }
61
55
  it { should be_nil }
62
56
  end
63
57
 
64
58
  context 'test that has already ended' do
65
59
  let(:end_at) { DateTime.now.prev_year.to_s }
66
60
  let(:buckets) { [1, 2, 3] }
67
- let(:variants) { [{ 'name' => 'enabled', 'chance_weight' => 1 }] }
61
+ let(:variants) { [OpenStruct.new(name: 'enabled', accumulated_chance_weight: 2)] }
68
62
  it { should be_nil }
69
63
  end
70
64
 
71
65
  context 'two variants' do
72
66
  let(:name) { 'button' }
73
- let(:variants) {
67
+ let(:variants) do
74
68
  [
75
- { 'name' => 'red', 'chance_weight' => 1 },
76
- { 'name' => 'blue', 'chance_weight' => 1 }
69
+ OpenStruct.new(name: 'red', accumulated_chance_weight: 1),
70
+ OpenStruct.new(name: 'blue', accumulated_chance_weight: 2)
77
71
  ]
78
- }
72
+ end
79
73
 
80
74
  specify 'half the ids fall under one, other under other' do
81
75
  reds = thousand_variants.select { |variant| variant == 'red' }
@@ -0,0 +1,22 @@
1
+ {
2
+ "salt": "aaaa1111",
3
+ "bucket_count": 1000,
4
+ "ab_tests": [
5
+ {
6
+ "id": 42,
7
+ "name": "all_buckets",
8
+ "seed": "bbbb2222",
9
+ "all_buckets": true,
10
+ "variants": [
11
+ {
12
+ "name": "green",
13
+ "chance_weight": 1
14
+ },
15
+ {
16
+ "name": "red",
17
+ "chance_weight": 1
18
+ }
19
+ ]
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "test": "all_buckets",
3
+ "variants": {
4
+ "green": [1, 2, 3, 4, 6, 10, 13, 16, 18, 19, 21, 22, 23, 25, 26, 30, 33, 34, 38, 41, 42, 43, 44, 47, 48, 51, 52, 53, 55, 57, 58, 59, 61, 65, 66, 72, 73, 74, 75, 79, 81, 83, 88, 89, 91, 92, 97, 98],
5
+ "red": [5, 7, 8, 9, 11, 12, 14, 15, 17, 20, 24, 27, 28, 29, 31, 32, 35, 36, 37, 39, 40, 45, 46, 49, 50, 54, 56, 60, 62, 63, 64, 67, 68, 69, 70, 71, 76, 77, 78, 80, 82, 84, 85, 86, 87, 90, 93, 94, 95, 96, 99, 100]
6
+ }
7
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "salt": "9a38d3194fc4c1b43c500c551d3c80a0",
3
+ "bucket_count": 4,
4
+ "ab_tests": [
5
+ {
6
+ "id": 2,
7
+ "name": "already_finished",
8
+ "seed": "792e3b32a6dd59d3f300b2ac8b4caa09",
9
+ "start_at": "1605-11-05T00:00:00+01:00",
10
+ "end_at": "2014-02-01T02:15:00+03:00",
11
+ "all_buckets": true,
12
+ "variants": [
13
+ {
14
+ "name": "green",
15
+ "chance_weight": 1
16
+ }
17
+ ]
18
+ }
19
+ ]
20
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "test": "already_finished",
3
+ "variants": {
4
+ "": [291605, 304029, 260623, 330108, 579320, 530360, 372392, 700813, 244200, 541818, 432400, 943631, 437817, 226191, 569129, 436127, 490360, 929904, 603358, 437705, 878118, 807422, 972099, 404677, 67694, 384045, 48608, 518304, 830501, 902758, 915681, 211550, 748688, 606226, 309490, 12909, 762, 560680, 945245, 795680, 358793, 850366, 278017, 765934, 378015, 156624, 878811, 814470, 830857, 712438]
5
+ }
6
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "salt": "1fd3eca97a86c4458bf3b6308ed4d990",
3
+ "bucket_count": 1000000,
4
+ "ab_tests": [
5
+ {
6
+ "id": 11,
7
+ "name": "big_weights",
8
+ "seed": "5aae80d25c5ba1931cbd1bfabada7628",
9
+ "all_buckets": true,
10
+ "variants": [
11
+ {
12
+ "name": "green",
13
+ "chance_weight": 1111111
14
+ },
15
+ {
16
+ "name": "red",
17
+ "chance_weight": 2222222
18
+ }
19
+ ]
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "test": "big_weights",
3
+ "variants": {
4
+ "green": [4, 6, 8, 10, 20, 21, 26, 27, 33, 35, 37, 38, 41, 43, 45, 48, 54, 63, 68, 72, 74, 75, 77, 80, 82, 84, 89, 92, 94, 96, 97, 98, 100000, 222222, 3333333],
5
+ "red": [1, 2, 3, 5, 7, 9, 11, 12, 13, 14, 15, 16, 17, 18, 19, 22, 23, 24, 25, 28, 29, 30, 31, 32, 34, 36, 39, 40, 42, 44, 46, 47, 49, 50, 51, 52, 53, 55, 56, 57, 58, 59, 60, 61, 62, 64, 65, 66, 67, 69, 70, 71, 73, 76, 78, 79, 81, 83, 85, 86, 87, 88, 90, 91, 93, 95, 99, 100, 44444444, 5555555555, 66666666666666, 7777777777777777777, 888888888888888]
6
+ }
7
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "salt": "26c9eb8052e063c0c90ccfc2b2b8d3f9",
3
+ "bucket_count": 10,
4
+ "ab_tests": [
5
+ {
6
+ "id": 5,
7
+ "name": "explicit_times",
8
+ "seed": "2a14d8c5a30618799547e428dfa4a807",
9
+ "start_at": "2013-01-01T00:00:00+00:00",
10
+ "end_at": "2222-02-02T02:02:02+02:00",
11
+ "all_buckets": true,
12
+ "variants": [
13
+ {
14
+ "name": "green",
15
+ "chance_weight": 1
16
+ }
17
+ ]
18
+ }
19
+ ]
20
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "test": "explicit_times",
3
+ "variants": {
4
+ "green": [246634, 189232, 261124, 258312, 143345, 61526, 563378, 183884, 292018, 85369, 241636, 797666, 364106, 514650, 185138, 881918, 349238, 605224, 290364, 517050, 923044, 522072, 745441, 194585, 18954, 836159, 733972, 847258, 330674, 547290, 614244, 230881, 994375, 933292, 184046, 954399, 951499, 986197, 302526, 245495, 406331, 162540, 782412, 502991, 926435, 439917, 949962, 161191, 435941, 809921]
5
+ }
6
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "salt": "61b81d7844c40c4057ebe6fc1a7a4a54",
3
+ "bucket_count": 10,
4
+ "ab_tests": [
5
+ {
6
+ "id": 7,
7
+ "name": "few_buckets",
8
+ "seed": "f63eb232778ee3b0ad7b8f515dea18a2",
9
+ "buckets": [1, 2, 3, 4],
10
+ "variants": [
11
+ {
12
+ "name": "green",
13
+ "chance_weight": 1
14
+ },
15
+ {
16
+ "name": "red",
17
+ "chance_weight": 1
18
+ }
19
+ ]
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "test": "few_buckets",
3
+ "variants": {
4
+ "": [2, 5, 6, 8, 9],
5
+ "green": [1, 3, 4],
6
+ "red": [7, 10, 11]
7
+ }
8
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "salt": "2",
3
+ "bucket_count": 333,
4
+ "ab_tests": [
5
+ {
6
+ "id": 2,
7
+ "name": "has_not_started",
8
+ "seed": "2",
9
+ "start_at": "2444-01-01T00:00:00+00:00",
10
+ "all_buckets": true,
11
+ "variants": [
12
+ {
13
+ "name": "green",
14
+ "chance_weight": 1
15
+ }
16
+ ]
17
+ }
18
+ ]
19
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "test": "has_not_started",
3
+ "variants": {
4
+ "": [183045, 691488, 793455, 522272, 961068, 778995, 753374, 995071, 219, 9, 765648, 519309, 581785, 419174, 62315, 901724, 739080, 528967, 744073, 751906, 886404, 457847, 817084, 160980, 302650, 911722, 447343, 871078, 450743, 879249, 276816, 826730, 697169, 344382, 511211, 154169, 55093, 648220, 964453, 862546, 208317, 16098, 341078, 111032, 277598, 876165, 650530, 446612, 583245, 7724]
5
+ }
6
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "salt": "61b81d7844c40c4057ebe6fc1a7a4a54",
3
+ "bucket_count": 22222,
4
+ "ab_tests": [
5
+ {
6
+ "id": 8,
7
+ "name": "one",
8
+ "seed": "f63eb232778ee3b0ad7b8f515dea18a2",
9
+ "all_buckets": true,
10
+ "variants": [
11
+ {
12
+ "name": "green",
13
+ "chance_weight": 1
14
+ }
15
+ ]
16
+ },
17
+ {
18
+ "id": 9,
19
+ "name": "two",
20
+ "seed": "f63eb232778ee3b0ad7b8f515dea18a2",
21
+ "all_buckets": true,
22
+ "variants": [
23
+ {
24
+ "name": "red",
25
+ "chance_weight": 1
26
+ }
27
+ ]
28
+ }
29
+ ]
30
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "test": "two",
3
+ "variants": {
4
+ "red": [376108, 78463, 987379, 883214, 484448, 91173, 404534, 192288, 422282, 409742, 56641, 738643, 314843, 970353, 47323, 702285, 574459, 545482, 359063, 972527, 83465, 779284, 632264, 888819, 18155, 863241, 451140, 396046, 622149, 206291, 129760, 357063, 749273, 46561, 18731, 237123, 486940, 911556, 919840, 235874, 290879, 480711, 738111, 266919, 532775, 323640, 313300, 820330, 532474, 431884]
5
+ }
6
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "salt": "61b81d7844c40c4057ebe6fc1a7a4a54",
3
+ "bucket_count": 20,
4
+ "ab_tests": [
5
+ {
6
+ "id": 6,
7
+ "name": "multiple_variants",
8
+ "seed": "1995f6808c018b6dd91a3bc927fd7e92",
9
+ "all_buckets": true,
10
+ "variants": [
11
+ {
12
+ "name": "green",
13
+ "chance_weight": 1
14
+ },
15
+ {
16
+ "name": "red",
17
+ "chance_weight": 1
18
+ },
19
+ {
20
+ "name": "blue",
21
+ "chance_weight": 1
22
+ },
23
+ {
24
+ "name": "orange",
25
+ "chance_weight": 1
26
+ }
27
+ ]
28
+ }
29
+ ]
30
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "test": "multiple_variants",
3
+ "variants": {
4
+ "green": [1, 2, 3, 7, 8, 9, 12, 19, 31, 34, 37, 43, 49, 60, 62, 64, 65, 68, 71, 75, 78, 79, 87, 91, 94, 95],
5
+ "orange": [4, 11, 15, 21, 25, 32, 33, 35, 38, 40, 46, 48, 56, 57, 59, 61, 63, 74, 77, 84, 88],
6
+ "blue": [5, 10, 14, 16, 17, 20, 24, 28, 30, 42, 44, 45, 53, 55, 67, 69, 76, 81, 86, 90, 92, 93, 98, 100],
7
+ "red": [6, 13, 18, 22, 23, 26, 27, 29, 36, 39, 41, 47, 50, 51, 52, 54, 58, 66, 70, 72, 73, 80, 82, 83, 85, 89, 96, 97, 99]
8
+ }
9
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "salt": "dddd4444",
3
+ "bucket_count": 200,
4
+ "ab_tests": [
5
+ {
6
+ "id": 1,
7
+ "name": "no_buckets",
8
+ "seed": "cccc3333",
9
+ "variants": [
10
+ {
11
+ "name": "nononononononon",
12
+ "chance_weight": 1
13
+ }
14
+ ]
15
+ }
16
+ ]
17
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "test": "no_buckets",
3
+ "variants": {
4
+ "": [221661, 512005, 429796, 860028, 10044, 249906, 491257, 188883, 793187, 86200, 155542, 257569, 534172, 506476, 461784, 123346, 882492, 933085, 105147, 575351, 756286, 293415, 946852, 894051, 109248, 285169, 499834, 422777, 793, 353968, 470821, 300689, 994515, 352756, 527621, 811497, 12865, 405584, 783387, 23868, 1577, 402157, 645302, 921879, 743877, 65532, 729269, 704958, 473143, 621616]
5
+ }
6
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "salt": "89808d1afb4a334119e6d017289cf46a",
3
+ "bucket_count": 21,
4
+ "ab_tests": [
5
+ {
6
+ "id": 12,
7
+ "name": "no_variants",
8
+ "seed": "6e76ebb941d7e65a59d28071d00e3910",
9
+ "all_buckets": true,
10
+ "variants": []
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "test": "no_variants",
3
+ "variants": {
4
+ "": [622251, 272484, 204185, 253530, 568849, 707722, 716486, 995162, 824709, 767433, 492812, 248413, 167496, 656567, 151008, 922024, 329511, 720392, 736955, 661974, 97247, 543083, 639996, 699049, 157179, 307863, 889578, 95851, 677312, 337342, 324874, 566262, 625613, 394275, 363587, 441650, 117251, 689288, 313266, 732267, 263893, 27783, 357379, 673605, 127934, 940549, 254671, 732244, 628961, 87713]
5
+ }
6
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "salt": "61b81d7844c40c4057ebe6fc1a7a4a54",
3
+ "bucket_count": 2,
4
+ "ab_tests": [
5
+ {
6
+ "id": 4,
7
+ "name": "zero_buckets",
8
+ "seed": "1995f6808c018b6dd91a3bc927fd7e92",
9
+ "buckets": [],
10
+ "variants": [
11
+ {
12
+ "name": "green",
13
+ "chance_weight": 1
14
+ }
15
+ ]
16
+ }
17
+ ]
18
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "test": "zero_buckets",
3
+ "variants": {
4
+ "": [407157, 680726, 413486, 225300, 884397, 493575, 77961, 191171, 540935, 504825, 276950, 750121, 964532, 987549, 250191, 644840, 906385, 584061, 56659, 397052, 544631, 107797, 485280, 418972, 547068, 726896, 224553, 369481, 713741, 739533, 639550, 895066, 397180, 829280, 581343, 566108, 438772, 173296, 99849, 106325, 308317, 135582, 82913, 994233, 882570, 72915, 699850, 161517, 420297, 836392]
5
+ }
6
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "salt": "199c13b89551aa4127370496ee557892",
3
+ "bucket_count": 44,
4
+ "ab_tests": [
5
+ {
6
+ "id": 6,
7
+ "name": "zero_weights",
8
+ "seed": "2631740ab92aa67dd64172e4f740896d",
9
+ "all_buckets": true,
10
+ "variants": [
11
+ {
12
+ "name": "zero",
13
+ "chance_weight": 0
14
+ },
15
+ {
16
+ "name": "weight",
17
+ "chance_weight": 0
18
+ }
19
+ ]
20
+ }
21
+ ]
22
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "test": "zero_weight",
3
+ "variants": {
4
+ "": [407157, 680726, 413486, 225300, 884397, 493575, 77961, 191171, 540935, 504825, 276950, 750121, 964532, 987549, 250191, 644840, 906385, 584061, 56659, 397052, 544631, 107797, 485280, 418972, 547068, 726896, 224553, 369481, 713741, 739533, 639550, 895066, 397180, 829280, 581343, 566108, 438772, 173296, 99849, 106325, 308317, 135582, 82913, 994233, 882570, 72915, 699850, 161517, 420297, 836392]
5
+ }
6
+ }
@@ -0,0 +1,28 @@
1
+ require 'spec_helper'
2
+ require 'json-schema'
3
+
4
+ describe 'ab' do
5
+ path_to_schema = "#{File.dirname(__FILE__)}/../config.json"
6
+
7
+ Dir.glob("#{File.dirname(__FILE__)}/examples/**").each do |name|
8
+ context "#{name} example" do
9
+ let(:input) { IO.read("#{name}/input.json") }
10
+ let(:output) { JSON.parse(IO.read("#{name}/output.json")) }
11
+
12
+ specify 'validates against schema' do
13
+ result = JSON::Validator.validate(path_to_schema, input, version: :draft3)
14
+ result.should be_true
15
+ end
16
+
17
+ specify 'correctly assigns variants' do
18
+ cases = []
19
+ output['variants'].map { |variant, ids| ids.each { |id| cases << [id, variant] } }
20
+
21
+ cases.each do |id, variant|
22
+ tests = Ab::Tests.new(input, id)
23
+ tests.send(output['test']).variant.to_s.should == variant
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -4,12 +4,14 @@ module Ab
4
4
  describe NullTest do
5
5
  subject { NullTest.new }
6
6
 
7
+ its(:variant) { should be_nil }
8
+
7
9
  specify 'does not raise for method ending in question mark' do
8
- expect{ subject.bla? }.to_not raise_error
10
+ expect { subject.bla? }.to_not raise_error
9
11
  end
10
12
 
11
13
  specify 'raises for method not ending in question mark' do
12
- expect{ subject.bla }.to raise_error
14
+ expect { subject.bla }.to raise_error
13
15
  end
14
16
  end
15
17
  end
data/spec/test_spec.rb CHANGED
@@ -16,10 +16,27 @@ module Ab
16
16
  it { should == 'test' }
17
17
  end
18
18
 
19
+ context '#weight_sum' do
20
+ subject { test.weight_sum }
21
+ let(:test_hash) do
22
+ { 'variants' => [{ 'chance_weight' => 1 },
23
+ { 'chance_weight' => 2 }] }
24
+ end
25
+ it { should == 3 }
26
+ end
27
+
19
28
  context '#start_at' do
20
29
  subject { test.start_at }
21
- let(:test_hash) { { 'start_at' => '2014-05-27T11:56:25+03:00' } }
22
- it { should == DateTime.new(2014, 5, 27, 11, 56, 25, '+3') }
30
+
31
+ context 'nil' do
32
+ let(:test_hash) { {} }
33
+ it { should < DateTime.new(1977) }
34
+ end
35
+
36
+ context 'april fools' do
37
+ let(:test_hash) { { 'start_at' => '2014-04-01T12:00:00+00:00' } }
38
+ it { should == DateTime.new(2014, 4, 1, 12) }
39
+ end
23
40
  end
24
41
 
25
42
  context '#end_at' do
data/spec/tests_spec.rb CHANGED
@@ -2,36 +2,62 @@ require 'spec_helper'
2
2
 
3
3
  module Ab
4
4
  describe Tests do
5
- describe '.new' do
6
- subject { Tests.new(config, id) }
7
- let(:id) { 1 }
5
+ let(:tests) { Tests.new(config, id) }
6
+ let(:config) { {} }
7
+ let(:id) { 1 }
8
+
9
+ shared_context 'simple config with feed' do
10
+ let(:config) do
11
+ {
12
+ 'salt' => 'anything',
13
+ 'bucket_count' => 1000,
14
+ 'ab_tests' => [{
15
+ 'name' => 'feed',
16
+ 'all_buckets' => true,
17
+ 'variants' => [{ 'name' => 'enabled', 'chance_weight' => 1 }]
18
+ }]
19
+ }
20
+ end
21
+ end
8
22
 
9
- context 'empty config' do
10
- let(:config) { {} }
23
+ describe '#respond_to?' do
24
+ subject { tests.respond_to?(method_name) }
11
25
 
12
- specify 'has no public methods' do
13
- (subject.public_methods(false) - [:method_missing, :respond_to?, :all]).count.should == 0
26
+ 1.upto(10).each do |i|
27
+ context "random method name of #{i} length" do
28
+ let(:method_name) { SecureRandom.hex(i) }
29
+ it { should be_true }
14
30
  end
31
+ end
32
+ end
33
+
34
+ describe '#method_missing' do
35
+ subject { tests.send(method_name) }
15
36
 
16
- specify 'does not raise if method is not existant' do
17
- expect{ subject.bla_bla_bla }.to_not raise_error
37
+ 1.upto(10).each do |i|
38
+ context "random method name of #{i} length" do
39
+ let(:method_name) { SecureRandom.hex(i) }
40
+ it { should be_kind_of(NullTest) }
18
41
  end
19
42
  end
43
+ end
44
+
45
+ describe '#all' do
46
+ include_context 'simple config with feed'
47
+ subject { tests.all }
48
+ it { should == { 'feed' => 'enabled' } }
49
+ end
50
+
51
+ describe '.new' do
52
+ subject { tests }
53
+
54
+ specify 'has no public methods' do
55
+ (subject.public_methods(false) - [:method_missing, :respond_to?, :all]).count.should == 0
56
+ end
20
57
 
21
58
  context 'single experiment with single variant' do
22
- let(:config) {
23
- {
24
- 'salt' => 'anything',
25
- 'bucket_count' => 1000,
26
- 'ab_tests' => [{
27
- 'name' => 'feed',
28
- 'buckets' => 'all',
29
- 'variants' => [{ 'name' => 'enabled', 'chance_weight' => 1 }]
30
- }]
31
- }
32
- }
59
+ include_context 'simple config with feed'
33
60
  its(:feed) { should be_kind_of AssignedTest }
34
- its(:all) { should == { 'feed' => 'enabled' } }
35
61
  end
36
62
  end
37
63
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vinted-ab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mindaugas Mozūras
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2014-06-10 00:00:00.000000000 Z
12
+ date: 2014-06-16 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: hooks
@@ -85,6 +85,26 @@ dependencies:
85
85
  - - '>='
86
86
  - !ruby/object:Gem::Version
87
87
  version: 2.14.0
88
+ - !ruby/object:Gem::Dependency
89
+ name: json-schema
90
+ requirement: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ~>
93
+ - !ruby/object:Gem::Version
94
+ version: 2.2.2
95
+ - - '>='
96
+ - !ruby/object:Gem::Version
97
+ version: 2.2.2
98
+ type: :development
99
+ prerelease: false
100
+ version_requirements: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ~>
103
+ - !ruby/object:Gem::Version
104
+ version: 2.2.2
105
+ - - '>='
106
+ - !ruby/object:Gem::Version
107
+ version: 2.2.2
88
108
  description:
89
109
  email:
90
110
  - mindaugas.mozuras@gmail.com
@@ -94,11 +114,13 @@ extensions: []
94
114
  extra_rdoc_files: []
95
115
  files:
96
116
  - .gitignore
117
+ - .rubocop.yml
97
118
  - Gemfile
98
119
  - LICENSE
99
120
  - README.md
100
121
  - Rakefile
101
122
  - ab.gemspec
123
+ - config.json
102
124
  - lib/ab.rb
103
125
  - lib/ab/assigned_test.rb
104
126
  - lib/ab/null_test.rb
@@ -107,6 +129,31 @@ files:
107
129
  - lib/ab/variant.rb
108
130
  - lib/ab/version.rb
109
131
  - spec/assigned_test_spec.rb
132
+ - spec/examples/all_buckets/input.json
133
+ - spec/examples/all_buckets/output.json
134
+ - spec/examples/already_finished/input.json
135
+ - spec/examples/already_finished/output.json
136
+ - spec/examples/big_weights/input.json
137
+ - spec/examples/big_weights/output.json
138
+ - spec/examples/explicit_times/input.json
139
+ - spec/examples/explicit_times/output.json
140
+ - spec/examples/few_buckets/input.json
141
+ - spec/examples/few_buckets/output.json
142
+ - spec/examples/has_not_started/input.json
143
+ - spec/examples/has_not_started/output.json
144
+ - spec/examples/multiple_tests/input.json
145
+ - spec/examples/multiple_tests/output.json
146
+ - spec/examples/multiple_variants/input.json
147
+ - spec/examples/multiple_variants/output.json
148
+ - spec/examples/no_buckets/input.json
149
+ - spec/examples/no_buckets/output.json
150
+ - spec/examples/no_variants/input.json
151
+ - spec/examples/no_variants/output.json
152
+ - spec/examples/zero_buckets/input.json
153
+ - spec/examples/zero_buckets/output.json
154
+ - spec/examples/zero_weight/input.json
155
+ - spec/examples/zero_weight/output.json
156
+ - spec/integration_spec.rb
110
157
  - spec/null_test_spec.rb
111
158
  - spec/spec_helper.rb
112
159
  - spec/test_spec.rb