ab 0.0.1
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.
- data/.gitignore +17 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +29 -0
- data/Rakefile +1 -0
- data/ab.gemspec +21 -0
- data/lib/ab.rb +23 -0
- data/lib/ab/chance.rb +36 -0
- data/lib/ab/indexer.rb +75 -0
- data/lib/ab/railtie.rb +9 -0
- data/lib/ab/tester.rb +42 -0
- data/lib/ab/version.rb +3 -0
- data/lib/ab/view_helpers.rb +7 -0
- data/spec/ab_test_spec.rb +20 -0
- data/spec/chance_spec.rb +37 -0
- data/spec/int_indexer_spec.rb +35 -0
- data/spec/spec_helper.rb +2 -0
- data/spec/tester_spec.rb +48 -0
- metadata +89 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2013 sparkboxx
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# OleryAb
|
2
|
+
|
3
|
+
TODO: Write a gem description
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
gem 'olery_ab'
|
10
|
+
|
11
|
+
And then execute:
|
12
|
+
|
13
|
+
$ bundle
|
14
|
+
|
15
|
+
Or install it yourself as:
|
16
|
+
|
17
|
+
$ gem install olery_ab
|
18
|
+
|
19
|
+
## Usage
|
20
|
+
|
21
|
+
TODO: Write usage instructions here
|
22
|
+
|
23
|
+
## Contributing
|
24
|
+
|
25
|
+
1. Fork it
|
26
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
27
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
28
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
29
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
data/ab.gemspec
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'ab/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |gem|
|
7
|
+
gem.name = "ab"
|
8
|
+
gem.version = Ab::VERSION
|
9
|
+
gem.authors = ["sparkboxx", "giannismelidis"]
|
10
|
+
gem.email = ["wilcovanduinkerken@olery.com", "giannismelidis@olery.com"]
|
11
|
+
gem.description = %q{Basic AB testing gem}
|
12
|
+
gem.summary = %q{The gem provides a basic class Tester or view helper function ab that allows you to deterministically show a certain option (a/b/c/etc) to a user based on a certain chance (e.g. 1/5)}
|
13
|
+
gem.homepage = "http://github.com/olery/ab"
|
14
|
+
|
15
|
+
gem.files = `git ls-files`.split($/)
|
16
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
17
|
+
gem.test_files = gem.files.grep(%r{^(spec)/})
|
18
|
+
gem.require_paths = ["lib"]
|
19
|
+
|
20
|
+
gem.add_development_dependency("rspec")
|
21
|
+
end
|
data/lib/ab.rb
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
require "digest/sha1"
|
2
|
+
|
3
|
+
require "ab/version"
|
4
|
+
require "ab/tester"
|
5
|
+
require "ab/indexer"
|
6
|
+
require "ab/chance"
|
7
|
+
require 'ab/view_helpers'
|
8
|
+
|
9
|
+
require 'ab/railtie' if defined?(Rails)
|
10
|
+
|
11
|
+
#
|
12
|
+
# Overall Initial Idea:
|
13
|
+
#
|
14
|
+
|
15
|
+
#options = []
|
16
|
+
#options << link_to("some kind of url")
|
17
|
+
#options << link_to("the other url")
|
18
|
+
|
19
|
+
# = ab_test(:options=>options, :chance=>"1/1", :input=>user.id, :name=>"announcements") # returns an option
|
20
|
+
|
21
|
+
module Ab
|
22
|
+
end
|
23
|
+
|
data/lib/ab/chance.rb
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
module Ab
|
2
|
+
class Chance
|
3
|
+
attr_reader :chances
|
4
|
+
|
5
|
+
def initialize(chance_string)
|
6
|
+
raw_chances = self.class.split_chance(chance_string)
|
7
|
+
validate_raw_chances(raw_chances)
|
8
|
+
|
9
|
+
@chances = self.class.normalize(raw_chances)
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.normalize(array)
|
13
|
+
sum = array.reduce(&:+)
|
14
|
+
factor = 100.to_f / sum
|
15
|
+
array.map{|e| e * factor}.map(&:to_i)
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.split_chance(string)
|
19
|
+
string.split("/").map(&:to_i)
|
20
|
+
end
|
21
|
+
|
22
|
+
def validate_raw_chances(chances)
|
23
|
+
chances.each do |chance|
|
24
|
+
raise ArgumentError.new("chance should be > 0") if chance < 0
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def count
|
29
|
+
chances.count
|
30
|
+
end
|
31
|
+
|
32
|
+
def to_a
|
33
|
+
chances
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
data/lib/ab/indexer.rb
ADDED
@@ -0,0 +1,75 @@
|
|
1
|
+
module Ab
|
2
|
+
class Indexer
|
3
|
+
def self.call(opts={})
|
4
|
+
validate_options(opts)
|
5
|
+
|
6
|
+
value = opts[:value].to_s
|
7
|
+
chances = opts[:chances]
|
8
|
+
salt = opts[:seed].to_s
|
9
|
+
|
10
|
+
seed = Digest::SHA1.hexdigest(salt + value).to_i
|
11
|
+
random_number = Randomizer.new(seed).rand(1..100)
|
12
|
+
|
13
|
+
sum = 0
|
14
|
+
chances.each_index do |index|
|
15
|
+
min = sum
|
16
|
+
max = chances[index] + sum
|
17
|
+
|
18
|
+
if (min..max).include?(random_number)
|
19
|
+
return index
|
20
|
+
end
|
21
|
+
|
22
|
+
sum = max
|
23
|
+
end
|
24
|
+
raise IndexError.new("Could not resolve any index for the given chances")
|
25
|
+
end
|
26
|
+
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def self.validate_options(opts)
|
31
|
+
keys = [:value, :chances, :seed]
|
32
|
+
keys.each do |key|
|
33
|
+
raise ArgumentError.new("Missing option: :#{key}") if opts[key].nil?
|
34
|
+
end
|
35
|
+
|
36
|
+
if opts[:chances].reduce(&:+)==0
|
37
|
+
raise ArgumentError.new("Chances does not actually contain any chance")
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
class Randomizer
|
42
|
+
attr_reader :randomizer
|
43
|
+
|
44
|
+
def initialize(seed)
|
45
|
+
if RUBY_VERSION =~ /^1.8/
|
46
|
+
@randomizer = OneEight.new(seed)
|
47
|
+
else
|
48
|
+
@randomizer = Random.new(seed)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def rand(value)
|
53
|
+
randomizer.rand(value)
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
class OneEight
|
59
|
+
def initialize(seed)
|
60
|
+
srand(seed)
|
61
|
+
end
|
62
|
+
|
63
|
+
def rand(value)
|
64
|
+
if value.kind_of?(Range)
|
65
|
+
first = value.first
|
66
|
+
last = value.last - first
|
67
|
+
return Kernel::rand(last) + first
|
68
|
+
else
|
69
|
+
Kernel::rand(value)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
data/lib/ab/railtie.rb
ADDED
data/lib/ab/tester.rb
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
module Ab
|
2
|
+
class Tester
|
3
|
+
attr_reader :options, :chances, :input, :name
|
4
|
+
|
5
|
+
def initialize(opts={})
|
6
|
+
opts = defaults.merge!(opts)
|
7
|
+
|
8
|
+
@options = opts[:options]
|
9
|
+
@chances = Chance.new(opts[:chances])
|
10
|
+
@input = opts[:input]
|
11
|
+
@indexer = opts[:indexer]
|
12
|
+
@name = opts[:name]
|
13
|
+
end
|
14
|
+
|
15
|
+
def valid?
|
16
|
+
chances.count == options.count
|
17
|
+
end
|
18
|
+
|
19
|
+
def validate!
|
20
|
+
raise ArgumentError unless valid?
|
21
|
+
end
|
22
|
+
|
23
|
+
def call(id)
|
24
|
+
validate!
|
25
|
+
index = @indexer.call(:value=>id,:chances=>chances.to_a, :seed=>name)
|
26
|
+
if block_given?
|
27
|
+
yield options[index]
|
28
|
+
else
|
29
|
+
return options[index]
|
30
|
+
end
|
31
|
+
end
|
32
|
+
alias_method :for, :call
|
33
|
+
|
34
|
+
private
|
35
|
+
def defaults
|
36
|
+
{ :options=>[true,false],
|
37
|
+
:chances=>"50/50",
|
38
|
+
:name=>"Please provide me a name",
|
39
|
+
:indexer=>Indexer }
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
data/lib/ab/version.rb
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'ab/view_helpers.rb'
|
3
|
+
|
4
|
+
describe "Helper Method" do
|
5
|
+
class View
|
6
|
+
include Ab::ViewHelpers
|
7
|
+
end
|
8
|
+
|
9
|
+
let(:view){ View.new}
|
10
|
+
|
11
|
+
it "should do nothing on empty calls" do
|
12
|
+
expect{view.ab}.to_not raise_error
|
13
|
+
end
|
14
|
+
|
15
|
+
it "should take multiple options" do
|
16
|
+
expect{view.ab(:options=>[1,2,3], :chance=>"1/2/2", :input=>50)}.to_not raise_error
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
20
|
+
|
data/spec/chance_spec.rb
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Ab::Chance do
|
4
|
+
it "should split a string into a an array of integers" do
|
5
|
+
Chance.split_chance("10/20/30").should eql([10,20,30])
|
6
|
+
end
|
7
|
+
|
8
|
+
it "should not accept minus chances" do
|
9
|
+
expect{Chance.new("-1/0/0")}.to raise_error(ArgumentError)
|
10
|
+
end
|
11
|
+
|
12
|
+
it "should return normalized chances" do
|
13
|
+
Chance.new("1/0/0").to_a.should eql([100,0,0])
|
14
|
+
Chance.new("0/1/0").to_a.should eql([0,100,0])
|
15
|
+
Chance.new("0/0/1").to_a.should eql([0,0,100])
|
16
|
+
end
|
17
|
+
|
18
|
+
context "normalizing" do
|
19
|
+
it "should normalize tens" do
|
20
|
+
Chance.normalize([10,15,25]).should eql([20,30,50])
|
21
|
+
end
|
22
|
+
|
23
|
+
it "should normalize thousands" do
|
24
|
+
Chance.normalize([1000,1500,2500]).should eql([20,30,50])
|
25
|
+
end
|
26
|
+
|
27
|
+
it "should normalize decimals" do
|
28
|
+
Chance.normalize([0.1,0.15,0.25]).should eql([20,30,50])
|
29
|
+
end
|
30
|
+
|
31
|
+
it "should cope with zero's" do
|
32
|
+
Chance.normalize([0,0,1]).should eql([0,0,100])
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Ab::Indexer do
|
4
|
+
let(:indexer){ Ab::Indexer }
|
5
|
+
|
6
|
+
|
7
|
+
it "should process a bunch of options" do
|
8
|
+
options = [
|
9
|
+
{:chances=>[100,0], :value=>123, :result=>0, :seed=>123},
|
10
|
+
{:chances=>[0,100], :value=>123, :result=>1, :seed=>123},
|
11
|
+
{:chances=>[-10,100], :value=>123, :result=>1, :seed=>123},
|
12
|
+
{:chances=>[100,0,0], :value=>123, :result=>0, :seed=>123},
|
13
|
+
{:chances=>[0,100,0], :value=>123, :result=>1, :seed=>123},
|
14
|
+
{:chances=>[0,0,100], :value=>123, :result=>2, :seed=>123},
|
15
|
+
]
|
16
|
+
|
17
|
+
options.each do |option|
|
18
|
+
indexer.call(option).should eql(option[:result])
|
19
|
+
end
|
20
|
+
|
21
|
+
end
|
22
|
+
|
23
|
+
it "raises ArgumentError when arguments are missing" do
|
24
|
+
expect{indexer.call()}.to raise_error(ArgumentError)
|
25
|
+
end
|
26
|
+
|
27
|
+
it "raises ArgumentError when chances are 0" do
|
28
|
+
expect{indexer.call(:chances=>[0,0], :value=>123, :seed=>123)}.to raise_error(ArgumentError)
|
29
|
+
end
|
30
|
+
|
31
|
+
it "raises Error when it can't resolve an index" do
|
32
|
+
expect{indexer.call(:chances=>[-10, -20], :value=>123, :seed=>123)}.to raise_error(IndexError)
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
data/spec/spec_helper.rb
ADDED
data/spec/tester_spec.rb
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Ab::Tester do
|
4
|
+
|
5
|
+
it "should initialize with no options" do
|
6
|
+
expect{Tester.new}.to_not raise_error
|
7
|
+
end
|
8
|
+
|
9
|
+
it "should initialize with options" do
|
10
|
+
expect{Tester.new(:options=>[1,2,3], :chances=>"1/2/2", :input=>50)}.to_not raise_error
|
11
|
+
end
|
12
|
+
|
13
|
+
it "should remember the options" do
|
14
|
+
Tester.new(:options=>[1,2,3]).options.should eql([1,2,3])
|
15
|
+
end
|
16
|
+
|
17
|
+
it "should remember the chances" do
|
18
|
+
Tester.new(:chances=>"10/10/10").chances.should_not be_nil
|
19
|
+
end
|
20
|
+
|
21
|
+
it "should remember the input" do
|
22
|
+
Tester.new(:input=>10).input.should eql(10)
|
23
|
+
end
|
24
|
+
|
25
|
+
it "should check if the amount of options equals the amount of chances" do
|
26
|
+
expect{Tester.new(:options=>[1,2,3], :chances=>"10/30").call}.to raise_error(ArgumentError)
|
27
|
+
end
|
28
|
+
|
29
|
+
it "should be able to set it's own indexer" do
|
30
|
+
Tester.new(:options=>[1,2,3],
|
31
|
+
:chances=>"0/0/1",
|
32
|
+
:indexer=>Proc.new{|a,b| 1}).call(1235).should eql(2)
|
33
|
+
end
|
34
|
+
|
35
|
+
it "uses a sane default indexer" do
|
36
|
+
Tester.new(:chances=>"1/0/0", :options=>[1,2,3]).call(1235).should eql(1)
|
37
|
+
Tester.new(:chances=>"0/1/0", :options=>[1,2,3]).call(1235).should eql(2)
|
38
|
+
Tester.new(:chances=>"0/0/1", :options=>[1,2,3]).call(1235).should eql(3)
|
39
|
+
end
|
40
|
+
|
41
|
+
it "should take a block and provide the result to the block" do
|
42
|
+
expect do |b|
|
43
|
+
Tester.new(:chances=>"1/0/0", :options=>[1,2,3])\
|
44
|
+
.call(1235, &b)
|
45
|
+
end.to yield_with_args(1)
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
metadata
ADDED
@@ -0,0 +1,89 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: ab
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- sparkboxx
|
9
|
+
- giannismelidis
|
10
|
+
autorequire:
|
11
|
+
bindir: bin
|
12
|
+
cert_chain: []
|
13
|
+
date: 2013-02-21 00:00:00.000000000 Z
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: rspec
|
17
|
+
requirement: !ruby/object:Gem::Requirement
|
18
|
+
none: false
|
19
|
+
requirements:
|
20
|
+
- - ! '>='
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: '0'
|
23
|
+
type: :development
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
26
|
+
none: false
|
27
|
+
requirements:
|
28
|
+
- - ! '>='
|
29
|
+
- !ruby/object:Gem::Version
|
30
|
+
version: '0'
|
31
|
+
description: Basic AB testing gem
|
32
|
+
email:
|
33
|
+
- wilcovanduinkerken@olery.com
|
34
|
+
- giannismelidis@olery.com
|
35
|
+
executables: []
|
36
|
+
extensions: []
|
37
|
+
extra_rdoc_files: []
|
38
|
+
files:
|
39
|
+
- .gitignore
|
40
|
+
- Gemfile
|
41
|
+
- LICENSE.txt
|
42
|
+
- README.md
|
43
|
+
- Rakefile
|
44
|
+
- ab.gemspec
|
45
|
+
- lib/ab.rb
|
46
|
+
- lib/ab/chance.rb
|
47
|
+
- lib/ab/indexer.rb
|
48
|
+
- lib/ab/railtie.rb
|
49
|
+
- lib/ab/tester.rb
|
50
|
+
- lib/ab/version.rb
|
51
|
+
- lib/ab/view_helpers.rb
|
52
|
+
- spec/ab_test_spec.rb
|
53
|
+
- spec/chance_spec.rb
|
54
|
+
- spec/int_indexer_spec.rb
|
55
|
+
- spec/spec_helper.rb
|
56
|
+
- spec/tester_spec.rb
|
57
|
+
homepage: http://github.com/olery/ab
|
58
|
+
licenses: []
|
59
|
+
post_install_message:
|
60
|
+
rdoc_options: []
|
61
|
+
require_paths:
|
62
|
+
- lib
|
63
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
64
|
+
none: false
|
65
|
+
requirements:
|
66
|
+
- - ! '>='
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
70
|
+
none: false
|
71
|
+
requirements:
|
72
|
+
- - ! '>='
|
73
|
+
- !ruby/object:Gem::Version
|
74
|
+
version: '0'
|
75
|
+
requirements: []
|
76
|
+
rubyforge_project:
|
77
|
+
rubygems_version: 1.8.24
|
78
|
+
signing_key:
|
79
|
+
specification_version: 3
|
80
|
+
summary: The gem provides a basic class Tester or view helper function ab that allows
|
81
|
+
you to deterministically show a certain option (a/b/c/etc) to a user based on a
|
82
|
+
certain chance (e.g. 1/5)
|
83
|
+
test_files:
|
84
|
+
- spec/ab_test_spec.rb
|
85
|
+
- spec/chance_spec.rb
|
86
|
+
- spec/int_indexer_spec.rb
|
87
|
+
- spec/spec_helper.rb
|
88
|
+
- spec/tester_spec.rb
|
89
|
+
has_rdoc:
|