trailguide 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 91de95da7df9831c3ab11b091d4caa81d566044ca054f450a4ece7b6c74e50eb
4
+ data.tar.gz: 3c4a00eccbbb4a3983a87462b52f6e7489ba4079f1f74f5e7bb53959b163a9dd
5
+ SHA512:
6
+ metadata.gz: fa89b438040e89c75f7ec183f3ded180c95506d205e60f3c7b1c099b13e63162e91b1205147176cb9271c4a7759f59b0ed1dff793a897c6fae253c0df056007d
7
+ data.tar.gz: e877c3aa6c6abcd9375cfafe45c30cea9e86f1a4ca0215a3fd0581b7337487c48ed2fe9ca05bd5f5768292901f2d4dda184f7a6ebe06d1b4cfdee9dbba014f69
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2019 Mark Rebec
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # TrailGuide
2
+ Short description and motivation.
3
+
4
+ ## Usage
5
+ How to use my plugin.
6
+
7
+ ## Installation
8
+ Add this line to your application's Gemfile:
9
+
10
+ ```ruby
11
+ gem 'trailguide'
12
+ ```
13
+
14
+ And then execute:
15
+ ```bash
16
+ $ bundle
17
+ ```
18
+
19
+ Or install it yourself as:
20
+ ```bash
21
+ $ gem install trailguide
22
+ ```
23
+
24
+ ## Contributing
25
+ Contribution directions go here.
26
+
27
+ ## License
28
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/config/routes.rb ADDED
@@ -0,0 +1,2 @@
1
+ TrailGuide::Engine.routes.draw do
2
+ end
@@ -0,0 +1,70 @@
1
+ module TrailGuide
2
+ module Adapters
3
+ module Participants
4
+ class Anonymous
5
+ include Canfig::Instance
6
+
7
+ class << self
8
+ alias_method :configure, :new
9
+ def new(context, &block)
10
+ configure(&block).new(context)
11
+ end
12
+ end
13
+
14
+ def initialize(&block)
15
+ configure do |config|
16
+ yield(config) if block_given?
17
+ end
18
+ end
19
+
20
+ # instance method, creates a new adapter and passes through config
21
+ def new(context)
22
+ Adapter.new(context, configuration)
23
+ end
24
+
25
+ class Adapter
26
+ attr_reader :context, :config
27
+
28
+ def initialize(context, config)
29
+ @context = context
30
+ @config = config
31
+ end
32
+
33
+ def [](key)
34
+ hash[key]
35
+ end
36
+
37
+ def []=(key, value)
38
+ hash[key] = value
39
+ end
40
+
41
+ def delete(key)
42
+ hash.delete(key)
43
+ end
44
+
45
+ def destroy!
46
+ @hash = nil
47
+ end
48
+
49
+ def keys
50
+ hash.keys
51
+ end
52
+
53
+ def key?(key)
54
+ hash.key?(key)
55
+ end
56
+
57
+ def to_h
58
+ hash.to_h
59
+ end
60
+
61
+ private
62
+
63
+ def hash
64
+ @hash ||= {}
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,99 @@
1
+ module TrailGuide
2
+ module Adapters
3
+ module Participants
4
+ class Cookie
5
+ include Canfig::Instance
6
+
7
+ # TODO maybe be a little better about checking for action dispatch, etc.?
8
+
9
+ class << self
10
+ alias_method :configure, :new
11
+ def new(context, &block)
12
+ configure(&block).new(context)
13
+ end
14
+ end
15
+
16
+ def initialize(&block)
17
+ configure do |config|
18
+ config.cookie = :trailguide
19
+ config.path = '/'
20
+ config.expiration = 1.year.to_i
21
+ # TODO other cookie options (domain, ssl, etc.)
22
+
23
+ yield(config) if block_given?
24
+ end
25
+ end
26
+
27
+ # instance method, creates a new adapter and passes through config
28
+ def new(context)
29
+ raise NoMethodError, "Your current context (#{context}) does not support cookies" unless context.respond_to?(:cookies, true)
30
+ Adapter.new(context, configuration)
31
+ end
32
+
33
+ class Adapter
34
+ attr_reader :context, :config
35
+
36
+ def initialize(context, config)
37
+ @context = context
38
+ @config = config
39
+ end
40
+
41
+ def [](key)
42
+ cookie[key.to_s]
43
+ end
44
+
45
+ def []=(key, value)
46
+ cookie.merge!({key.to_s => value})
47
+ write_cookie
48
+ end
49
+
50
+ def delete(key)
51
+ cookie.tap { |h| h.delete(key.to_s) }
52
+ write_cookie
53
+ end
54
+
55
+ def destroy!
56
+ cookies.delete(config.cookie.to_s)
57
+ end
58
+
59
+ def keys
60
+ cookie.keys
61
+ end
62
+
63
+ def key?(key)
64
+ cookie.key?(key)
65
+ end
66
+
67
+ def to_h
68
+ cookie.to_h
69
+ end
70
+
71
+ private
72
+
73
+ def cookie
74
+ @cookie ||= begin
75
+ JSON.parse(cookies[config.cookie.to_s])
76
+ rescue
77
+ {}
78
+ end
79
+ end
80
+
81
+ def cookies
82
+ context.send(:cookies)
83
+ end
84
+
85
+ def write_cookie
86
+ cookies[config.cookie.to_s] = cookie_options.merge({value: cookie.as_json})
87
+ end
88
+
89
+ def cookie_options
90
+ {
91
+ expires: Time.now + config.expiration,
92
+ path: config.path
93
+ }
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,41 @@
1
+ module TrailGuide
2
+ module Adapters
3
+ module Participants
4
+ class Multi
5
+ include Canfig::Instance
6
+
7
+ class << self
8
+ alias_method :configure, :new
9
+ def new(context, &block)
10
+ configure(&block).new(context)
11
+ end
12
+ end
13
+
14
+ def initialize(&block)
15
+ configure do |config|
16
+ config.adapter = -> (context) do
17
+ if context.respond_to?(:current_user, true) && context.send(:current_user).present?
18
+ TrailGuide::Adapters::Participants::Redis
19
+ elsif context.respond_to?(:cookies, true)
20
+ TrailGuide::Adapters::Participants::Cookie
21
+ elsif context.respond_to?(:session, true)
22
+ TrailGuide::Adapters::Participants::Session
23
+ else
24
+ TrailGuide::Adapters::Participants::Anonymous
25
+ end
26
+ end
27
+
28
+ yield(config) if block_given?
29
+ end
30
+ end
31
+
32
+ # instance method, creates a new adapter and passes through config
33
+ def new(context)
34
+ adapter = configuration.adapter.call(context)
35
+ adapter = configuration.send(adapter) if adapter.is_a?(Symbol)
36
+ adapter.new(context)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,83 @@
1
+ module TrailGuide
2
+ module Adapters
3
+ module Participants
4
+ class Redis
5
+ include Canfig::Instance
6
+
7
+ class << self
8
+ alias_method :configure, :new
9
+ def new(context, **opts, &block)
10
+ configure(&block).new(context, **opts)
11
+ end
12
+ end
13
+
14
+ def initialize(&block)
15
+ configure do |config|
16
+ config.namespace = :participants
17
+ config.lookup = -> (context) { context.current_user.id }
18
+ config.expiration = nil
19
+
20
+ yield(config) if block_given?
21
+ end
22
+ end
23
+
24
+ # instance method, creates a new adapter and passes through config
25
+ def new(context, **opts)
26
+ Adapter.new(context, configuration, **opts)
27
+ end
28
+
29
+ class Adapter
30
+ attr_reader :context, :config, :storage_key
31
+
32
+ def initialize(context, config, key: nil)
33
+ @context = context
34
+ @config = config
35
+
36
+ if key
37
+ @storage_key = "#{config.namespace}:#{key}"
38
+ elsif config.lookup
39
+ if config.lookup.respond_to?(:call)
40
+ key = config.lookup.call(context)
41
+ else
42
+ key = context.send(config.lookup)
43
+ end
44
+ @storage_key = "#{config.namespace}:#{key}"
45
+ else
46
+ # TODO better error
47
+ raise ArgumentError, "You must configure a `lookup` proc"
48
+ end
49
+ end
50
+
51
+ def [](field)
52
+ TrailGuide.redis.hget(storage_key, field.to_s)
53
+ end
54
+
55
+ def []=(field, value)
56
+ TrailGuide.redis.hset(storage_key, field.to_s, value)
57
+ TrailGuide.redis.expire(storage_key, config.expiration) if config.expiration
58
+ end
59
+
60
+ def delete(field)
61
+ TrailGuide.redis.hdel(storage_key, field.to_s)
62
+ end
63
+
64
+ def destroy!
65
+ TrailGuide.redis.del(storage_key)
66
+ end
67
+
68
+ def keys
69
+ TrailGuide.redis.hkeys(storage_key)
70
+ end
71
+
72
+ def key?(field)
73
+ TrailGuide.redis.hexists(storage_key, field.to_s)
74
+ end
75
+
76
+ def to_h
77
+ TrailGuide.redis.hgetall(storage_key)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,73 @@
1
+ module TrailGuide
2
+ module Adapters
3
+ module Participants
4
+ class Session
5
+ include Canfig::Instance
6
+
7
+ class << self
8
+ alias_method :configure, :new
9
+ def new(context, &block)
10
+ configure(&block).new(context)
11
+ end
12
+ end
13
+
14
+ def initialize(&block)
15
+ configure do |config|
16
+ config.key = :trailguide
17
+
18
+ yield(config) if block_given?
19
+ end
20
+ end
21
+
22
+ # instance method, creates a new adapter and passes through config
23
+ def new(context)
24
+ raise NoMethodError, "Your current context (#{context}) does not support sessions" unless context.respond_to?(:session, true)
25
+ Adapter.new(context, configuration)
26
+ end
27
+
28
+ class Adapter
29
+ attr_reader :context, :config
30
+
31
+ def initialize(context, config)
32
+ @context = context
33
+ @config = config
34
+ end
35
+
36
+ def [](key)
37
+ session[key]
38
+ end
39
+
40
+ def []=(key, value)
41
+ session[key] = value
42
+ end
43
+
44
+ def delete(key)
45
+ session.delete(key)
46
+ end
47
+
48
+ def destroy!
49
+ context.send(:session).delete(config.key)
50
+ end
51
+
52
+ def keys
53
+ session.keys
54
+ end
55
+
56
+ def key?(key)
57
+ session.key?(key)
58
+ end
59
+
60
+ def to_h
61
+ session.to_h
62
+ end
63
+
64
+ private
65
+
66
+ def session
67
+ context.send(:session)[config.key] ||= {}
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,107 @@
1
+ require 'trail_guide/unity'
2
+
3
+ module TrailGuide
4
+ module Adapters
5
+ module Participants
6
+ class Unity < Multi
7
+ attr_reader :context, :unity
8
+
9
+ def initialize(&block)
10
+ configure do |config|
11
+ config.visitor_cookie = nil
12
+ config.user_id_key = :id
13
+
14
+ config.user_adapter = TrailGuide::Adapters::Participants::Redis.configure do |config|
15
+ config.namespace = 'unity:users'
16
+ config.lookup = -> (user_id) { user_id }
17
+ end
18
+
19
+ config.visitor_adapter = TrailGuide::Adapters::Participants::Redis.configure do |config|
20
+ config.namespace = 'unity:visitors'
21
+ config.lookup = -> (visitor_id) { visitor_id }
22
+ end
23
+
24
+ config.anonymous_adapter = TrailGuide::Adapters::Participants::Anonymous
25
+
26
+ yield(config) if block_given?
27
+ end
28
+ end
29
+
30
+ def new(context)
31
+ @context = context
32
+ @unity = TrailGuide::Unity.new
33
+
34
+ if trailguide_context?
35
+ unity.user_id ||= context.send(:try, :trailguide_user).try(configuration.user_id_key)
36
+ unity.visitor_id ||= context.send(:try, :trailguide_visitor)
37
+ end
38
+
39
+ if logged_in_context?
40
+ unity.user_id ||= context.send(:current_user).send(configuration.user_id_key)
41
+ end
42
+
43
+ if logged_out_context?
44
+ unity.visitor_id ||= context.send(:cookies)[configuration.visitor_cookie].gsub(/(%22|")/, '')
45
+ end
46
+
47
+ unity.sync!
48
+ merge! if unity.synced?
49
+
50
+ adapter = configuration.send("#{context_type}_adapter".to_sym)
51
+ if anonymous_context?
52
+ adapter.new(context)
53
+ else
54
+ adapter.new(unity.send("#{context_type}_id".to_sym))
55
+ end
56
+ end
57
+
58
+ protected
59
+
60
+ def context_type
61
+ if visitor_context?
62
+ return :visitor
63
+ end
64
+
65
+ if user_context?
66
+ return :user
67
+ end
68
+
69
+ return :anonymous
70
+ end
71
+
72
+ def merge!
73
+ user_adapter.keys.each do |key|
74
+ visitor_adapter[key] = user_adapter[key] unless visitor_adapter[key].present?
75
+ end
76
+ user_adapter.destroy!
77
+ end
78
+
79
+ def trailguide_context?
80
+ context.send(:try, :trailguide_user).present? ||
81
+ context.send(:try, :trailguide_visitor).present?
82
+ end
83
+
84
+ def logged_in_context?
85
+ context.send(:try, :current_user).present?
86
+ end
87
+
88
+ def logged_out_context?
89
+ return false unless configuration.visitor_cookie.present?
90
+ context.respond_to?(:cookies, true) && context.send(:cookies)[configuration.visitor_cookie].present?
91
+ end
92
+
93
+ def user_context?
94
+ unity.user_id.present?
95
+ end
96
+
97
+ def visitor_context?
98
+ unity.visitor_id.present?
99
+ end
100
+
101
+ def anonymous_context?
102
+ !visitor_context? && !user_context?
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,12 @@
1
+ require "trail_guide/adapters/participants/anonymous"
2
+ require "trail_guide/adapters/participants/session"
3
+ require "trail_guide/adapters/participants/cookie"
4
+ require "trail_guide/adapters/participants/redis"
5
+ require "trail_guide/adapters/participants/multi"
6
+
7
+ module TrailGuide
8
+ module Adapters
9
+ module Participants
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,6 @@
1
+ require "trail_guide/adapters/participants"
2
+
3
+ module TrailGuide
4
+ module Adapters
5
+ end
6
+ end
@@ -0,0 +1,48 @@
1
+ require 'simple-random'
2
+
3
+ module TrailGuide
4
+ module Algorithms
5
+ class Bandit
6
+ attr_reader :experiment
7
+
8
+ def self.choose!(experiment, **opts)
9
+ new(experiment).choose!(**opts)
10
+ end
11
+
12
+ def initialize(experiment)
13
+ @experiment = experiment
14
+ end
15
+
16
+ def choose!(**opts)
17
+ guess = best_guess
18
+ experiment.variants.find { |var| var == guess }
19
+ end
20
+
21
+ private
22
+
23
+ def best_guess
24
+ @best_guess ||= begin
25
+ guesses = {}
26
+ experiment.variants.each do |variant|
27
+ guesses[variant.name] = arm_guess(variant.participants, variant.converted)
28
+ end
29
+ gmax = guesses.values.max
30
+ best = guesses.keys.select { |name| guesses[name] == gmax }
31
+ best.sample
32
+ end
33
+ end
34
+
35
+ def arm_guess(participants, conversions)
36
+ a = [participants, 0].max
37
+ b = [(participants - conversions), 0].max
38
+ s = SimpleRandom.new
39
+ s.set_seed
40
+ s.beta(a+fairness_constant, b+fairness_constant)
41
+ end
42
+
43
+ def fairness_constant
44
+ 7
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,26 @@
1
+ module TrailGuide
2
+ module Algorithms
3
+ class Distributed
4
+ attr_reader :experiment
5
+
6
+ def self.choose!(experiment, **opts)
7
+ new(experiment).choose!(**opts)
8
+ end
9
+
10
+ def initialize(experiment)
11
+ @experiment = experiment
12
+ end
13
+
14
+ def choose!(**opts)
15
+ variants.sample
16
+ end
17
+
18
+ private
19
+
20
+ def variants
21
+ groups = experiment.variants.group_by(&:participants)
22
+ groups.min_by { |c,g| c }.last
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,19 @@
1
+ module TrailGuide
2
+ module Algorithms
3
+ class Random
4
+ attr_reader :experiment
5
+
6
+ def self.choose!(experiment, **opts)
7
+ new(experiment).choose!(**opts)
8
+ end
9
+
10
+ def initialize(experiment)
11
+ @experiment = experiment
12
+ end
13
+
14
+ def choose!(**opts)
15
+ experiment.variants.sample
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,25 @@
1
+ module TrailGuide
2
+ module Algorithms
3
+ class Weighted
4
+ attr_reader :experiment
5
+
6
+ def self.choose!(experiment, **opts)
7
+ new(experiment).choose!(**opts)
8
+ end
9
+
10
+ def initialize(experiment)
11
+ @experiment = experiment
12
+ end
13
+
14
+ def choose!(**opts)
15
+ weights = experiment.variants.map(&:weight)
16
+ reference = rand * weights.inject(:+)
17
+
18
+ experiment.variants.zip(weights).each do |variant,weight|
19
+ return variant if weight >= reference
20
+ reference -= weight
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end