trailguide 0.2.1 → 0.3.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +191 -293
  3. data/app/views/trail_guide/admin/experiments/_btn_join.html.erb +1 -1
  4. data/app/views/trail_guide/admin/experiments/_header.html.erb +3 -3
  5. data/config/initializers/admin.rb +19 -0
  6. data/config/initializers/experiment.rb +261 -0
  7. data/config/initializers/trailguide.rb +6 -279
  8. data/lib/trail_guide/adapters.rb +2 -0
  9. data/lib/trail_guide/adapters/experiments.rb +8 -0
  10. data/lib/trail_guide/adapters/experiments/redis.rb +48 -0
  11. data/lib/trail_guide/adapters/participants/cookie.rb +1 -0
  12. data/lib/trail_guide/adapters/participants/unity.rb +9 -1
  13. data/lib/trail_guide/adapters/variants.rb +8 -0
  14. data/lib/trail_guide/adapters/variants/redis.rb +52 -0
  15. data/lib/trail_guide/admin/engine.rb +1 -0
  16. data/lib/trail_guide/algorithms.rb +4 -0
  17. data/lib/trail_guide/algorithms/algorithm.rb +29 -0
  18. data/lib/trail_guide/algorithms/bandit.rb +9 -18
  19. data/lib/trail_guide/algorithms/distributed.rb +8 -15
  20. data/lib/trail_guide/algorithms/random.rb +2 -12
  21. data/lib/trail_guide/algorithms/static.rb +34 -0
  22. data/lib/trail_guide/algorithms/weighted.rb +5 -17
  23. data/lib/trail_guide/catalog.rb +79 -35
  24. data/lib/trail_guide/config.rb +2 -4
  25. data/lib/trail_guide/engine.rb +2 -1
  26. data/lib/trail_guide/experiments/base.rb +41 -24
  27. data/lib/trail_guide/experiments/combined_config.rb +4 -0
  28. data/lib/trail_guide/experiments/config.rb +59 -30
  29. data/lib/trail_guide/experiments/participant.rb +4 -2
  30. data/lib/trail_guide/helper.rb +4 -216
  31. data/lib/trail_guide/helper/experiment_proxy.rb +160 -0
  32. data/lib/trail_guide/helper/helper_proxy.rb +62 -0
  33. data/lib/trail_guide/metrics/config.rb +2 -0
  34. data/lib/trail_guide/metrics/goal.rb +17 -15
  35. data/lib/trail_guide/participant.rb +10 -2
  36. data/lib/trail_guide/unity.rb +17 -8
  37. data/lib/trail_guide/variant.rb +15 -11
  38. data/lib/trail_guide/version.rb +2 -2
  39. metadata +13 -3
@@ -1,3 +1,5 @@
1
+ require "trail_guide/adapters/experiments"
2
+ require "trail_guide/adapters/variants"
1
3
  require "trail_guide/adapters/participants"
2
4
 
3
5
  module TrailGuide
@@ -0,0 +1,8 @@
1
+ require 'trail_guide/adapters/experiments/redis'
2
+
3
+ module TrailGuide
4
+ module Adapters
5
+ module Experiments
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,48 @@
1
+ module TrailGuide
2
+ module Adapters
3
+ module Experiments
4
+ class Redis
5
+ attr_reader :experiment
6
+ delegate :storage_key, to: :experiment
7
+
8
+ def initialize(experiment, redis: nil)
9
+ @experiment = experiment
10
+ @redis = redis
11
+ end
12
+
13
+ def redis
14
+ @redis ||= TrailGuide.redis
15
+ end
16
+
17
+ def get(attr)
18
+ redis.hget(storage_key, attr.to_s)
19
+ end
20
+
21
+ def set(attr, val)
22
+ redis.hset(storage_key, attr.to_s, val.to_s)
23
+ val.to_s
24
+ end
25
+
26
+ def setnx(attr, val)
27
+ val.to_s if redis.hsetnx(storage_key, attr.to_s, val.to_s)
28
+ end
29
+
30
+ def delete(attr)
31
+ redis.hdel(storage_key, attr.to_s)
32
+ end
33
+
34
+ def exists?(attr)
35
+ redis.hexists(storage_key, attr.to_s)
36
+ end
37
+
38
+ def persisted?
39
+ redis.exists(storage_key)
40
+ end
41
+
42
+ def destroy
43
+ redis.del(storage_key)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -35,6 +35,7 @@ module TrailGuide
35
35
  end
36
36
 
37
37
  def destroy!
38
+ @cookie = nil
38
39
  cookies.delete(config.cookie.to_s)
39
40
  end
40
41
 
@@ -8,6 +8,10 @@ module TrailGuide
8
8
 
9
9
  def initialize(&block)
10
10
  configure do |config|
11
+ # TODO make visitor/user configuration more flexible, allow stuff like:
12
+ #
13
+ # config.visitor = -> (context) { context.cookies['visitor_id'] }
14
+ # config.user = -> (context) { context.current_user.try(:id) }
11
15
  config.visitor_cookie = nil
12
16
  config.user_id_key = :id
13
17
 
@@ -17,6 +21,7 @@ module TrailGuide
17
21
  config.expiration = 1.year.seconds
18
22
  end
19
23
 
24
+ # TODO use cookie or session adapter by default instead?
20
25
  config.visitor_adapter = TrailGuide::Adapters::Participants::Redis.configure do |config|
21
26
  config.namespace = 'unity:visitors'
22
27
  config.lookup = -> (visitor_id) { visitor_id }
@@ -43,7 +48,7 @@ module TrailGuide
43
48
  end
44
49
 
45
50
  if logged_out_context?
46
- unity.visitor_id ||= context.send(:cookies)[configuration.visitor_cookie].gsub(/(%22|")/, '')
51
+ unity.visitor_id ||= context.send(:cookies)[configuration.visitor_cookie]
47
52
  end
48
53
 
49
54
  unity.sync!
@@ -57,9 +62,12 @@ module TrailGuide
57
62
  end
58
63
  end
59
64
 
65
+ # TODO introduce Unity::Adapter class, which wraps BOTH/ALL of the configured adapters (either anonymous, visitor, user, or visitor+user) to keep everything in sync between them
66
+
60
67
  protected
61
68
 
62
69
  def context_type
70
+ # TODO allow a configuration.preference when both visitor and user context are available?
63
71
  if visitor_context?
64
72
  return :visitor
65
73
  end
@@ -0,0 +1,8 @@
1
+ require 'trail_guide/adapters/variants/redis'
2
+
3
+ module TrailGuide
4
+ module Adapters
5
+ module Variants
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,52 @@
1
+ module TrailGuide
2
+ module Adapters
3
+ module Variants
4
+ class Redis
5
+ attr_reader :variant
6
+ delegate :storage_key, to: :variant
7
+
8
+ def initialize(variant, redis: nil)
9
+ @variant = variant
10
+ @redis = redis
11
+ end
12
+
13
+ def redis
14
+ @redis ||= TrailGuide.redis
15
+ end
16
+
17
+ def get(attr)
18
+ redis.hget(storage_key, attr.to_s)
19
+ end
20
+
21
+ def set(attr, val)
22
+ redis.hset(storage_key, attr.to_s, val.to_s)
23
+ val.to_s
24
+ end
25
+
26
+ def setnx(attr, val)
27
+ val.to_s if redis.hsetnx(storage_key, attr.to_s, val.to_s)
28
+ end
29
+
30
+ def increment(attr, cnt=1)
31
+ redis.hincrby(storage_key, attr.to_s, cnt)
32
+ end
33
+
34
+ def delete(attr)
35
+ redis.hdel(storage_key, attr.to_s)
36
+ end
37
+
38
+ def exists?(attr)
39
+ redis.hexists(storage_key, attr.to_s)
40
+ end
41
+
42
+ def persisted?
43
+ redis.exists(storage_key)
44
+ end
45
+
46
+ def destroy
47
+ redis.del(storage_key)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -8,6 +8,7 @@ module TrailGuide
8
8
  end
9
9
 
10
10
  paths["config/routes.rb"] = "config/routes/admin.rb"
11
+ paths["config/initializers"] = ["config/initializers/admin.rb", "config/initializers/assets.rb"]
11
12
  end
12
13
  end
13
14
  end
@@ -1,7 +1,9 @@
1
+ require "trail_guide/algorithms/algorithm"
1
2
  require "trail_guide/algorithms/weighted"
2
3
  require "trail_guide/algorithms/distributed"
3
4
  require "trail_guide/algorithms/bandit"
4
5
  require "trail_guide/algorithms/random"
6
+ require "trail_guide/algorithms/static"
5
7
 
6
8
  module TrailGuide
7
9
  module Algorithms
@@ -15,6 +17,8 @@ module TrailGuide
15
17
  algo = TrailGuide::Algorithms::Distributed
16
18
  when :random
17
19
  algo = TrailGuide::Algorithms::Random
20
+ when :static
21
+ algo = TrailGuide::Algorithms::Static
18
22
  else
19
23
  algo = algo.constantize if algo.is_a?(String)
20
24
  end
@@ -0,0 +1,29 @@
1
+ module TrailGuide
2
+ module Algorithms
3
+ class Algorithm
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
+ raise NotImplementedError, 'You must define a `#choose!(**opts)` method for your algorithm'
16
+ end
17
+
18
+ protected
19
+
20
+ def control
21
+ experiment.control
22
+ end
23
+
24
+ def variants
25
+ experiment.variants
26
+ end
27
+ end
28
+ end
29
+ end
@@ -2,32 +2,23 @@ require 'simple-random'
2
2
 
3
3
  module TrailGuide
4
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
-
5
+ class Bandit < Algorithm
16
6
  def choose!(**opts)
17
- guess = best_guess
18
- experiment.variants.find { |var| var == guess }
7
+ variants.find { |var| var == best_guess }
19
8
  end
20
9
 
21
10
  private
22
11
 
12
+ def guesses
13
+ @guesses ||= variants.map do |variant|
14
+ [variant.name, arm_guess(variant.participants, variant.converted)]
15
+ end.to_h
16
+ end
17
+
23
18
  def best_guess
24
19
  @best_guess ||= begin
25
- guesses = {}
26
- experiment.variants.each do |variant|
27
- guesses[variant.name] = arm_guess(variant.participants, variant.converted)
28
- end
29
20
  gmax = guesses.values.max
30
- best = guesses.keys.select { |name| guesses[name] == gmax }
21
+ best = guesses.keys.select { |name| guesses[name] == gmax }
31
22
  best.sample
32
23
  end
33
24
  end
@@ -1,25 +1,18 @@
1
1
  module TrailGuide
2
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
-
3
+ class Distributed < Algorithm
14
4
  def choose!(**opts)
15
- variants.sample
5
+ options.sample
16
6
  end
17
7
 
18
8
  private
19
9
 
20
- def variants
21
- groups = experiment.variants.group_by(&:participants)
22
- groups.min_by { |c,g| c }.last
10
+ def grouped
11
+ @grouped ||= variants.group_by(&:participants)
12
+ end
13
+
14
+ def options
15
+ @options ||= grouped.min_by { |c,g| c }.last
23
16
  end
24
17
  end
25
18
  end
@@ -1,18 +1,8 @@
1
1
  module TrailGuide
2
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
-
3
+ class Random < Algorithm
14
4
  def choose!(**opts)
15
- experiment.variants.sample
5
+ variants.sample
16
6
  end
17
7
  end
18
8
  end
@@ -0,0 +1,34 @@
1
+ module TrailGuide
2
+ module Algorithms
3
+ class Static < Algorithm
4
+ def self.choose!(experiment, metadata: nil, &block)
5
+ new(experiment, &block).choose!(metadata: metadata)
6
+ end
7
+
8
+ def initialize(experiment=nil, &block)
9
+ raise ArgumentError, 'You must provide a comparison block when using the static algorithm' unless block_given?
10
+ @block = block
11
+ super(experiment)
12
+ end
13
+
14
+ def new(experiment)
15
+ TrailGuide.logger.warn "WARNING: Using the Static algorithm for an experiment which is configured with sticky_assignment. You should either use a different algorithm or configure sticky_assignment for the `#{experiment.experiment_name}` experiment." if experiment.configuration.sticky_assignment?
16
+ self.class.new(experiment, &@block)
17
+ end
18
+
19
+ def choose!(metadata: nil)
20
+ return control unless metadata.present?
21
+
22
+ variant = variants.find do |variant|
23
+ @block.call(variant.metadata, metadata)
24
+ end
25
+
26
+ variant || control
27
+ rescue => e
28
+ TrailGuide.logger.error "#{e.class.name}: #{e.message}"
29
+ TrailGuide.logger.error e.backtrace.first
30
+ control
31
+ end
32
+ end
33
+ end
34
+ end
@@ -1,23 +1,11 @@
1
1
  module TrailGuide
2
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
-
3
+ class Weighted < Algorithm
14
4
  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
5
+ reference = rand * variants.sum(&:weight)
6
+ variants.each do |variant|
7
+ return variant if variant.weight >= reference
8
+ reference -= variant.weight
21
9
  end
22
10
  end
23
11
  end
@@ -7,19 +7,23 @@ module TrailGuide
7
7
  @catalog ||= new
8
8
  end
9
9
 
10
- def load_experiments!
10
+ def load_experiments!(configs: [], classes: [])
11
11
  @catalog = nil
12
12
 
13
13
  # Load experiments from YAML configs if any exists
14
- load_yaml_experiments(Rails.root.join("config/experiments.yml"))
15
- Dir[Rails.root.join("config/experiments/**/*.yml")].each { |f| load_yaml_experiments(f) }
14
+ [configs].flatten.each do |path|
15
+ Dir[Rails.root.join(path)].each { |f| load_yaml_experiments(f) if ['.yml', '.yaml'].include?(File.extname(f)) }
16
+ end
16
17
 
17
18
  # Load experiments from ruby configs if any exist
18
- DSL.instance_eval(File.read(Rails.root.join("config/experiments.rb"))) if File.exists?(Rails.root.join("config/experiments.rb"))
19
- Dir[Rails.root.join("config/experiments/**/*.rb")].each { |f| DSL.instance_eval(File.read(f)) }
19
+ [configs].flatten.each do |path|
20
+ Dir[Rails.root.join(path)].each { |f| DSL.instance_eval(File.read(f)) if File.extname(f) == '.rb' }
21
+ end
20
22
 
21
23
  # Load any experiment classes defined in the app
22
- Dir[Rails.root.join("app/experiments/**/*.rb")].each { |f| load f }
24
+ [classes].flatten.each do |path|
25
+ Dir[Rails.root.join(path)].each { |f| load f }
26
+ end
23
27
  end
24
28
 
25
29
  def load_yaml_experiments(file)
@@ -46,7 +50,7 @@ module TrailGuide
46
50
 
47
51
  expgoals.each do |expgoal|
48
52
  goal expgoal
49
- end
53
+ end if expgoals.present?
50
54
 
51
55
  config.control = options[:control] if options[:control]
52
56
  config.groups = options[:groups] if options[:groups]
@@ -76,15 +80,24 @@ module TrailGuide
76
80
  end
77
81
  end
78
82
 
79
- delegate :combined_experiment, to: :class
80
- attr_reader :experiments
83
+ attr_reader :experiments, :combined
84
+ delegate :new, to: :class
85
+ delegate :each, to: :experiments
81
86
 
82
- def initialize(experiments=[])
87
+ def initialize(experiments=[], combined=[])
83
88
  @experiments = experiments
89
+ @combined = combined
84
90
  end
85
91
 
86
- def each(&block)
87
- experiments.each(&block)
92
+ def combined_experiment(exp, name)
93
+ combo = @combined.find do |cex|
94
+ cex.experiment_name == name.to_s.underscore.to_sym &&
95
+ cex.parent.experiment_name == exp.experiment_name
96
+ end
97
+ return combo if combo.present?
98
+ combo = self.class.combined_experiment(exp, name)
99
+ @combined << combo
100
+ combo
88
101
  end
89
102
 
90
103
  def groups
@@ -100,47 +113,49 @@ module TrailGuide
100
113
  end
101
114
  end.flatten
102
115
 
103
- self.class.new(exploded)
116
+ new(exploded, @combined)
104
117
  end
105
118
 
106
119
  def calibrating
107
- self.class.new(to_a.select(&:calibrating?))
120
+ new(to_a.select(&:calibrating?), @combined)
108
121
  end
109
122
 
110
123
  def started
111
- self.class.new(to_a.select { |e| e.started? && !e.winner? })
124
+ new(to_a.select { |e| e.started? && !e.winner? }, @combined)
112
125
  end
113
126
 
114
127
  def scheduled
115
- self.class.new(to_a.select { |e| e.scheduled? && !e.winner? })
128
+ new(to_a.select { |e| e.scheduled? && !e.winner? }, @combined)
116
129
  end
117
130
 
118
131
  def running
119
- self.class.new(to_a.select { |e| e.running? && !e.winner? })
132
+ new(to_a.select { |e| e.running? && !e.winner? }, @combined)
120
133
  end
121
134
 
122
135
  def paused
123
- self.class.new(to_a.select { |e| e.paused? && !e.winner? })
136
+ new(to_a.select { |e| e.paused? && !e.winner? }, @combined)
124
137
  end
125
138
 
126
139
  def stopped
127
- self.class.new(to_a.select { |e| e.stopped? && !e.winner? })
140
+ new(to_a.select { |e| e.stopped? && !e.winner? }, @combined)
128
141
  end
129
142
 
130
143
  def ended
131
- self.class.new(to_a.select(&:winner?))
144
+ new(to_a.select(&:winner?), @combined)
132
145
  end
133
146
 
134
147
  def unstarted
135
- self.class.new(to_a.select { |e| !e.started? && !e.calibrating? && !e.scheduled? && !e.winner? })
148
+ new(to_a.select { |e| !e.started? && !e.calibrating? && !e.scheduled? && !e.winner? }, @combined)
136
149
  end
137
150
 
138
151
  def not_running
139
- self.class.new(to_a.select { |e| !e.running? })
152
+ new(to_a.select { |e| !e.running? }, @combined)
140
153
  end
141
154
 
142
155
  def by_started
143
156
  scoped = to_a.sort do |a,b|
157
+ # TODO finish implementing specs, then implement `experiment.fresh?`, then (maybe) re-work this all
158
+ # into an experiment spaceship operator
144
159
  if !(a.started? || a.scheduled? || a.winner?) && !(b.started? || b.scheduled? || b.winner?)
145
160
  a.experiment_name.to_s <=> b.experiment_name.to_s
146
161
  elsif !(a.started? || a.scheduled? || a.winner?)
@@ -159,32 +174,48 @@ module TrailGuide
159
174
  elsif !a.running? && b.running?
160
175
  1
161
176
  elsif a.running? && b.running?
162
- a.started_at <=> b.started_at
177
+ if a.started_at == b.started_at
178
+ a.experiment_name.to_s <=> b.experiment_name.to_s
179
+ else
180
+ a.started_at <=> b.started_at
181
+ end
163
182
  elsif a.paused? && !b.paused?
164
183
  -1
165
184
  elsif !a.paused? && b.paused?
166
185
  1
167
186
  elsif a.paused? && b.paused?
168
- a.paused_at <=> b.paused_at
187
+ if a.paused_at == b.paused_at
188
+ a.experiment_name.to_s <=> b.experiment_name.to_s
189
+ else
190
+ a.paused_at <=> b.paused_at
191
+ end
169
192
  elsif a.scheduled? && !b.scheduled?
170
193
  -1
171
194
  elsif !a.scheduled? && b.scheduled?
172
195
  1
173
196
  elsif a.scheduled? && b.scheduled?
174
- a.started_at <=> b.started_at
197
+ if a.started_at == b.started_at
198
+ a.experiment_name.to_s <=> b.experiment_name.to_s
199
+ else
200
+ a.started_at <=> b.started_at
201
+ end
175
202
  elsif a.stopped? && !b.stopped?
176
- -1
203
+ -1 # TODO remove unused case
177
204
  elsif !a.stopped? && b.stopped?
178
- 1
205
+ 1 # TODO remove unused case
179
206
  elsif a.stopped? && b.stopped?
180
- a.stopped_at <=> b.stopped_at
207
+ if a.stopped_at == b.stopped_at
208
+ a.experiment_name.to_s <=> b.experiment_name.to_s
209
+ else
210
+ a.stopped_at <=> b.stopped_at
211
+ end
181
212
  else
182
213
  a.experiment_name.to_s <=> b.experiment_name.to_s
183
214
  end
184
215
  end
185
216
  end
186
217
 
187
- self.class.new(scoped)
218
+ new(scoped, @combined)
188
219
  end
189
220
 
190
221
  def find(name)
@@ -227,7 +258,7 @@ module TrailGuide
227
258
  end
228
259
  end
229
260
 
230
- self.class.new(selected)
261
+ new(selected, @combined)
231
262
  end
232
263
 
233
264
  def register(klass)
@@ -235,8 +266,13 @@ module TrailGuide
235
266
  klass
236
267
  end
237
268
 
238
- def deregister(key)
239
- # TODO (mostly only useful for engine specs)
269
+ def deregister(key, remove_const=false)
270
+ klass = find(key)
271
+ return unless klass.present?
272
+ experiments.delete(klass)
273
+ return klass unless remove_const && klass.name.present?
274
+ Object.send(:remove_const, :"#{klass.name}")
275
+ return key
240
276
  end
241
277
 
242
278
  def export
@@ -256,9 +292,9 @@ module TrailGuide
256
292
 
257
293
  experiment.reset!
258
294
  TrailGuide.redis.hsetnx(experiment.storage_key, 'name', experiment.experiment_name)
259
- TrailGuide.redis.hset(experiment.storage_key, 'started_at', est['started_at']) if est['started_at'].present?
260
- TrailGuide.redis.hset(experiment.storage_key, 'paused_at', est['paused_at']) if est['paused_at'].present?
261
- TrailGuide.redis.hset(experiment.storage_key, 'stopped_at', est['stopped_at']) if est['stopped_at'].present?
295
+ TrailGuide.redis.hset(experiment.storage_key, 'started_at', DateTime.parse(est['started_at']).to_i) if est['started_at'].present?
296
+ TrailGuide.redis.hset(experiment.storage_key, 'paused_at', DateTime.parse(est['paused_at']).to_i) if est['paused_at'].present?
297
+ TrailGuide.redis.hset(experiment.storage_key, 'stopped_at', DateTime.parse(est['stopped_at']).to_i) if est['stopped_at'].present?
262
298
  TrailGuide.redis.hset(experiment.storage_key, 'winner', est['winner']) if est['winner'].present?
263
299
 
264
300
  est['variants'].each do |var,vst|
@@ -277,6 +313,13 @@ module TrailGuide
277
313
  end
278
314
  end
279
315
 
316
+ def missing
317
+ TrailGuide.redis.keys.select do |key|
318
+ exp = key.split(':').first
319
+ find(exp).nil?
320
+ end
321
+ end
322
+
280
323
  def orphaned(key, trace)
281
324
  added = TrailGuide.redis.sadd("orphans:#{key}", trace)
282
325
  TrailGuide.redis.expire("orphans:#{key}", 15.minutes.seconds)
@@ -319,6 +362,7 @@ module TrailGuide
319
362
  end
320
363
  end
321
364
 
365
+ # TrailGuide.catalog
322
366
  def self.catalog
323
367
  TrailGuide::Catalog.catalog
324
368
  end