trailguide 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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +28 -0
- data/config/routes.rb +2 -0
- data/lib/trail_guide/adapters/participants/anonymous.rb +70 -0
- data/lib/trail_guide/adapters/participants/cookie.rb +99 -0
- data/lib/trail_guide/adapters/participants/multi.rb +41 -0
- data/lib/trail_guide/adapters/participants/redis.rb +83 -0
- data/lib/trail_guide/adapters/participants/session.rb +73 -0
- data/lib/trail_guide/adapters/participants/unity.rb +107 -0
- data/lib/trail_guide/adapters/participants.rb +12 -0
- data/lib/trail_guide/adapters.rb +6 -0
- data/lib/trail_guide/algorithms/bandit.rb +48 -0
- data/lib/trail_guide/algorithms/distributed.rb +26 -0
- data/lib/trail_guide/algorithms/random.rb +19 -0
- data/lib/trail_guide/algorithms/weighted.rb +25 -0
- data/lib/trail_guide/algorithms.rb +24 -0
- data/lib/trail_guide/catalog.rb +71 -0
- data/lib/trail_guide/engine.rb +65 -0
- data/lib/trail_guide/experiment.rb +327 -0
- data/lib/trail_guide/helper.rb +185 -0
- data/lib/trail_guide/participant.rb +110 -0
- data/lib/trail_guide/unity.rb +87 -0
- data/lib/trail_guide/variant.rb +103 -0
- data/lib/trail_guide/version.rb +14 -0
- data/lib/trailguide.rb +57 -0
- metadata +153 -0
@@ -0,0 +1,110 @@
|
|
1
|
+
module TrailGuide
|
2
|
+
class Participant
|
3
|
+
attr_reader :context
|
4
|
+
delegate :key?, :keys, :[], :[]=, :delete, :destroy!, :to_h, to: :adapter
|
5
|
+
|
6
|
+
def initialize(context, adapter: nil)
|
7
|
+
@context = context
|
8
|
+
@adapter = adapter.new(context) unless adapter.nil?
|
9
|
+
end
|
10
|
+
|
11
|
+
def adapter
|
12
|
+
@adapter ||= begin
|
13
|
+
config_adapter = TrailGuide.configuration.adapter
|
14
|
+
case config_adapter
|
15
|
+
when :cookie
|
16
|
+
config_adapter = TrailGuide::Adapters::Participants::Cookie
|
17
|
+
when :session
|
18
|
+
config_adapter = TrailGuide::Adapters::Participants::Session
|
19
|
+
when :redis
|
20
|
+
config_adapter = TrailGuide::Adapters::Participants::Redis
|
21
|
+
when :anonymous
|
22
|
+
config_adapter = TrailGuide::Adapters::Participants::Anonymous
|
23
|
+
when :multi
|
24
|
+
config_adapter = TrailGuide::Adapters::Participants::Multi
|
25
|
+
else
|
26
|
+
config_adapter = config_adapter.constantize if config_adapter.is_a?(String)
|
27
|
+
end
|
28
|
+
config_adapter.new(context)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def participating?(experiment, include_control=true)
|
33
|
+
return false unless adapter.key?(experiment.storage_key)
|
34
|
+
varname = adapter[experiment.storage_key]
|
35
|
+
variant = experiment.variants.find { |var| var == varname }
|
36
|
+
return false if !include_control && variant.control?
|
37
|
+
return false unless variant && adapter.key?(variant.storage_key)
|
38
|
+
|
39
|
+
chosen_at = Time.at(adapter[variant.storage_key].to_i)
|
40
|
+
chosen_at >= experiment.started_at
|
41
|
+
end
|
42
|
+
|
43
|
+
def converted?(experiment, checkpoint=nil)
|
44
|
+
if experiment.funnels.empty?
|
45
|
+
raise ArgumentError, "This experiment does not have any defined goal checkpoints" unless checkpoint.nil?
|
46
|
+
storage_key = "#{experiment.storage_key}:converted"
|
47
|
+
return false unless adapter.key?(storage_key)
|
48
|
+
|
49
|
+
converted_at = Time.at(adapter[storage_key].to_i)
|
50
|
+
converted_at >= experiment.started_at
|
51
|
+
elsif !checkpoint.nil?
|
52
|
+
raise ArgumentError, "Invalid goal checkpoint: #{checkpoint}" unless experiment.funnels.any? { |funnel| funnel == checkpoint.to_s.underscore.to_sym }
|
53
|
+
storage_key = "#{experiment.storage_key}:#{checkpoint.to_s.underscore}"
|
54
|
+
return false unless adapter.key?(storage_key)
|
55
|
+
|
56
|
+
converted_at = Time.at(adapter[storage_key].to_i)
|
57
|
+
converted_at >= experiment.started_at
|
58
|
+
else
|
59
|
+
experiment.funnels.each do |funnel|
|
60
|
+
storage_key = "#{experiment.storage_key}:#{funnel.to_s}"
|
61
|
+
next unless adapter.key?(storage_key)
|
62
|
+
converted_at = Time.at(adapter[storage_key].to_i)
|
63
|
+
return true if converted_at >= experiment.started_at
|
64
|
+
end
|
65
|
+
return false
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def participating!(variant)
|
70
|
+
adapter[variant.experiment.storage_key] = variant.name
|
71
|
+
adapter[variant.storage_key] = Time.now.to_i
|
72
|
+
end
|
73
|
+
|
74
|
+
def converted!(variant, checkpoint=nil, reset: false)
|
75
|
+
checkpoint ||= :converted
|
76
|
+
storage_key = "#{variant.experiment.storage_key}:#{checkpoint.to_s.underscore}"
|
77
|
+
|
78
|
+
if reset
|
79
|
+
adapter.delete(variant.experiment.storage_key)
|
80
|
+
adapter.delete(variant.storage_key)
|
81
|
+
adapter.delete(storage_key)
|
82
|
+
experiment.funnels.each do |funnel|
|
83
|
+
funnel_key = "#{variant.experiment.storage_key}:#{funnel.to_s}"
|
84
|
+
adapter.delete(funnel_key)
|
85
|
+
end
|
86
|
+
else
|
87
|
+
adapter[storage_key] = Time.now.to_i
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def active_experiments(include_control=true)
|
92
|
+
return false if adapter.keys.empty?
|
93
|
+
adapter.keys.map { |key| key.to_s.split(":").first.to_sym }.uniq.map do |key|
|
94
|
+
experiment = TrailGuide.catalog.find(key)
|
95
|
+
next unless experiment && experiment.started? && participating?(experiment, include_control)
|
96
|
+
[ experiment.experiment_name, adapter[experiment.storage_key] ]
|
97
|
+
end.compact.to_h
|
98
|
+
end
|
99
|
+
|
100
|
+
def participating_in_active_experiments?(include_control=true)
|
101
|
+
return false if adapter.keys.empty?
|
102
|
+
|
103
|
+
adapter.keys.any? do |key|
|
104
|
+
experiment_name = key.to_s.split(":").first.to_sym
|
105
|
+
experiment = TrailGuide.catalog.find(experiment_name)
|
106
|
+
experiment && experiment.started? && participating?(experiment, include_control)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
module TrailGuide
|
2
|
+
class Unity
|
3
|
+
NAMESPACE = :unity
|
4
|
+
|
5
|
+
def self.clear!
|
6
|
+
keys = TrailGuide.redis.keys("#{NAMESPACE}:*")
|
7
|
+
TrailGuide.redis.del *keys unless keys.empty?
|
8
|
+
end
|
9
|
+
|
10
|
+
attr_reader :visitor_id, :user_id
|
11
|
+
|
12
|
+
def initialize(user_id: nil, visitor_id: nil)
|
13
|
+
@user_id = user_id.to_s if user_id.present?
|
14
|
+
@visitor_id = visitor_id.to_s if visitor_id.present?
|
15
|
+
end
|
16
|
+
|
17
|
+
def user_id=(uid)
|
18
|
+
@user_id = uid.to_s
|
19
|
+
end
|
20
|
+
|
21
|
+
def visitor_id=(vid)
|
22
|
+
@visitor_id = vid.to_s
|
23
|
+
end
|
24
|
+
|
25
|
+
def stored_user_id
|
26
|
+
TrailGuide.redis.get(visitor_key) if visitor_id.present?
|
27
|
+
end
|
28
|
+
|
29
|
+
def stored_visitor_id
|
30
|
+
TrailGuide.redis.get(user_key) if user_id.present?
|
31
|
+
end
|
32
|
+
|
33
|
+
def valid?
|
34
|
+
visitor_id.present? && user_id.present?
|
35
|
+
end
|
36
|
+
|
37
|
+
def stored?
|
38
|
+
stored_visitor_id.present? && stored_user_id.present?
|
39
|
+
end
|
40
|
+
|
41
|
+
def synced?
|
42
|
+
valid? && stored? &&
|
43
|
+
stored_visitor_id == visitor_id &&
|
44
|
+
stored_user_id == user_id
|
45
|
+
end
|
46
|
+
|
47
|
+
def sync!
|
48
|
+
@user_id ||= stored_user_id
|
49
|
+
@visitor_id ||= stored_visitor_id
|
50
|
+
delete!
|
51
|
+
save!
|
52
|
+
end
|
53
|
+
|
54
|
+
def save!
|
55
|
+
return false unless valid?
|
56
|
+
TrailGuide.redis.set(user_key, visitor_id)
|
57
|
+
TrailGuide.redis.set(visitor_key, user_id)
|
58
|
+
end
|
59
|
+
|
60
|
+
def delete!
|
61
|
+
keys = []
|
62
|
+
keys << stored_user_key if stored_user_id.present?
|
63
|
+
keys << stored_visitor_key if stored_visitor_id.present?
|
64
|
+
keys << user_key if user_id.present?
|
65
|
+
keys << visitor_key if visitor_id.present?
|
66
|
+
TrailGuide.redis.del(*keys) unless keys.empty?
|
67
|
+
end
|
68
|
+
|
69
|
+
protected
|
70
|
+
|
71
|
+
def user_key
|
72
|
+
"#{NAMESPACE}:uids:#{user_id}"
|
73
|
+
end
|
74
|
+
|
75
|
+
def visitor_key
|
76
|
+
"#{NAMESPACE}:vids:#{visitor_id}"
|
77
|
+
end
|
78
|
+
|
79
|
+
def stored_user_key
|
80
|
+
"#{NAMESPACE}:uids:#{stored_user_id}"
|
81
|
+
end
|
82
|
+
|
83
|
+
def stored_visitor_key
|
84
|
+
"#{NAMESPACE}:vids:#{stored_visitor_id}"
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
module TrailGuide
|
2
|
+
class Variant
|
3
|
+
attr_reader :experiment, :name, :metadata, :weight
|
4
|
+
|
5
|
+
def initialize(experiment, name, metadata: {}, weight: 1, control: false)
|
6
|
+
@experiment = experiment
|
7
|
+
@name = name.to_s.underscore.to_sym
|
8
|
+
@metadata = metadata
|
9
|
+
@weight = weight
|
10
|
+
@control = control
|
11
|
+
end
|
12
|
+
|
13
|
+
def ==(other)
|
14
|
+
if other.is_a?(self.class)
|
15
|
+
return name == other.name && experiment == other.experiment
|
16
|
+
elsif other.is_a?(String) || other.is_a?(Symbol)
|
17
|
+
return name == other.to_s.underscore.to_sym
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
# TODO maybe track the control on the experiment itself, rather than as a
|
22
|
+
# flag on the variants like this?
|
23
|
+
|
24
|
+
# mark this variant as the control
|
25
|
+
def control!
|
26
|
+
@control = true
|
27
|
+
end
|
28
|
+
|
29
|
+
# check if this variant is the control
|
30
|
+
def control?
|
31
|
+
!!@control
|
32
|
+
end
|
33
|
+
|
34
|
+
# unmark this variant as the control
|
35
|
+
def variant!
|
36
|
+
@control = false
|
37
|
+
end
|
38
|
+
|
39
|
+
def persisted?
|
40
|
+
TrailGuide.redis.exists(storage_key)
|
41
|
+
end
|
42
|
+
|
43
|
+
def save!
|
44
|
+
TrailGuide.redis.hsetnx(storage_key, 'name', name)
|
45
|
+
end
|
46
|
+
|
47
|
+
def delete!
|
48
|
+
TrailGuide.redis.del(storage_key)
|
49
|
+
end
|
50
|
+
|
51
|
+
def reset!
|
52
|
+
delete! && save!
|
53
|
+
end
|
54
|
+
|
55
|
+
def participants
|
56
|
+
(TrailGuide.redis.hget(storage_key, 'participants') || 0).to_i
|
57
|
+
end
|
58
|
+
|
59
|
+
def converted(checkpoint=nil)
|
60
|
+
if experiment.funnels.empty?
|
61
|
+
raise ArgumentError, "This experiment does not have any defined goal checkpoints" unless checkpoint.nil?
|
62
|
+
(TrailGuide.redis.hget(storage_key, 'converted') || 0).to_i
|
63
|
+
elsif !checkpoint.nil?
|
64
|
+
raise ArgumentError, "Invalid goal checkpoint: #{checkpoint}" unless experiment.funnels.any? { |funnel| funnel == checkpoint.to_s.underscore.to_sym }
|
65
|
+
(TrailGuide.redis.hget(storage_key, checkpoint.to_s.underscore) || 0).to_i
|
66
|
+
else
|
67
|
+
experiment.funnels.sum do |checkpoint|
|
68
|
+
(TrailGuide.redis.hget(storage_key, checkpoint.to_s.underscore) || 0).to_i
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def unconverted
|
74
|
+
participants - converted
|
75
|
+
end
|
76
|
+
|
77
|
+
def increment_participation!
|
78
|
+
TrailGuide.redis.hincrby(storage_key, 'participants', 1)
|
79
|
+
end
|
80
|
+
|
81
|
+
def increment_conversion!(checkpoint=nil)
|
82
|
+
checkpoint ||= :converted
|
83
|
+
TrailGuide.redis.hincrby(storage_key, checkpoint.to_s.underscore, 1)
|
84
|
+
end
|
85
|
+
|
86
|
+
def as_json(opts={})
|
87
|
+
{
|
88
|
+
name: name,
|
89
|
+
control: control?,
|
90
|
+
weight: weight,
|
91
|
+
metadata: metadata.as_json,
|
92
|
+
}
|
93
|
+
end
|
94
|
+
|
95
|
+
def to_s
|
96
|
+
name.to_s
|
97
|
+
end
|
98
|
+
|
99
|
+
def storage_key
|
100
|
+
"#{experiment.experiment_name}:#{name}"
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
data/lib/trailguide.rb
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
require "canfig"
|
2
|
+
require "redis"
|
3
|
+
require "trail_guide/adapters"
|
4
|
+
require "trail_guide/algorithms"
|
5
|
+
require "trail_guide/participant"
|
6
|
+
require "trail_guide/variant"
|
7
|
+
require "trail_guide/experiment"
|
8
|
+
require "trail_guide/catalog"
|
9
|
+
require "trail_guide/helper"
|
10
|
+
require "trail_guide/engine"
|
11
|
+
|
12
|
+
module TrailGuide
|
13
|
+
include Canfig::Module
|
14
|
+
|
15
|
+
configure do |config|
|
16
|
+
config.redis = ENV['REDIS_URL']
|
17
|
+
config.disabled = false
|
18
|
+
config.start_manually = true
|
19
|
+
config.reset_manually = true
|
20
|
+
config.store_override = false
|
21
|
+
config.override_parameter = :experiment
|
22
|
+
config.allow_multiple_experiments = true # false / :control
|
23
|
+
config.algorithm = :weighted
|
24
|
+
config.adapter = :multi
|
25
|
+
|
26
|
+
config.on_experiment_choose = nil # -> (experiment, variant, metadata) { ... }
|
27
|
+
config.on_experiment_use = nil # -> (experiment, variant, metadata) { ... }
|
28
|
+
config.on_experiment_convert = nil # -> (experiment, variant, checkpoint, metadata) { ... }
|
29
|
+
|
30
|
+
config.on_experiment_start = nil # -> (experiment) { ... }
|
31
|
+
config.on_experiment_stop = nil # -> (experiment) { ... }
|
32
|
+
config.on_experiment_reset = nil # -> (experiment) { ... }
|
33
|
+
config.on_experiment_delete = nil # -> (experiment) { ... }
|
34
|
+
|
35
|
+
config.filtered_user_agents = []
|
36
|
+
config.filtered_ip_addresses = []
|
37
|
+
config.request_filter = -> (context) do
|
38
|
+
is_preview? ||
|
39
|
+
is_filtered_user_agent? ||
|
40
|
+
is_filtered_ip_address?
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.catalog
|
45
|
+
TrailGuide::Catalog.catalog
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.redis
|
49
|
+
@redis ||= begin
|
50
|
+
if ['Redis', 'Redis::Namespace'].include?(configuration.redis.class.name)
|
51
|
+
configuration.redis
|
52
|
+
else
|
53
|
+
Redis.new(url: configuration.redis)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
metadata
ADDED
@@ -0,0 +1,153 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: trailguide
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Mark Rebec
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2019-03-04 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rails
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '5'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '5'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: canfig
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 0.0.7
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 0.0.7
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: redis
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: simple-random
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 0.9.3
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: 0.9.3
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: sqlite3
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: 1.3.6
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: 1.3.6
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rspec-rails
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
description: Perform user experiments and A/B tests in your rails apps
|
98
|
+
email:
|
99
|
+
- mark@markrebec.com
|
100
|
+
executables: []
|
101
|
+
extensions: []
|
102
|
+
extra_rdoc_files: []
|
103
|
+
files:
|
104
|
+
- MIT-LICENSE
|
105
|
+
- README.md
|
106
|
+
- config/routes.rb
|
107
|
+
- lib/trail_guide/adapters.rb
|
108
|
+
- lib/trail_guide/adapters/participants.rb
|
109
|
+
- lib/trail_guide/adapters/participants/anonymous.rb
|
110
|
+
- lib/trail_guide/adapters/participants/cookie.rb
|
111
|
+
- lib/trail_guide/adapters/participants/multi.rb
|
112
|
+
- lib/trail_guide/adapters/participants/redis.rb
|
113
|
+
- lib/trail_guide/adapters/participants/session.rb
|
114
|
+
- lib/trail_guide/adapters/participants/unity.rb
|
115
|
+
- lib/trail_guide/algorithms.rb
|
116
|
+
- lib/trail_guide/algorithms/bandit.rb
|
117
|
+
- lib/trail_guide/algorithms/distributed.rb
|
118
|
+
- lib/trail_guide/algorithms/random.rb
|
119
|
+
- lib/trail_guide/algorithms/weighted.rb
|
120
|
+
- lib/trail_guide/catalog.rb
|
121
|
+
- lib/trail_guide/engine.rb
|
122
|
+
- lib/trail_guide/experiment.rb
|
123
|
+
- lib/trail_guide/helper.rb
|
124
|
+
- lib/trail_guide/participant.rb
|
125
|
+
- lib/trail_guide/unity.rb
|
126
|
+
- lib/trail_guide/variant.rb
|
127
|
+
- lib/trail_guide/version.rb
|
128
|
+
- lib/trailguide.rb
|
129
|
+
homepage: https://github.com/markrebec/trailguide
|
130
|
+
licenses:
|
131
|
+
- MIT
|
132
|
+
metadata: {}
|
133
|
+
post_install_message:
|
134
|
+
rdoc_options: []
|
135
|
+
require_paths:
|
136
|
+
- lib
|
137
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
138
|
+
requirements:
|
139
|
+
- - ">="
|
140
|
+
- !ruby/object:Gem::Version
|
141
|
+
version: '0'
|
142
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
143
|
+
requirements:
|
144
|
+
- - ">="
|
145
|
+
- !ruby/object:Gem::Version
|
146
|
+
version: '0'
|
147
|
+
requirements: []
|
148
|
+
rubyforge_project:
|
149
|
+
rubygems_version: 2.7.7
|
150
|
+
signing_key:
|
151
|
+
specification_version: 4
|
152
|
+
summary: User experiments for rails
|
153
|
+
test_files: []
|