split 2.1.0 → 2.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -5,9 +5,13 @@ rvm:
5
5
  gemfile:
6
6
  - gemfiles/4.1.gemfile
7
7
  - gemfiles/4.2.gemfile
8
+ - gemfiles/5.0.gemfile
8
9
 
9
10
  before_install:
10
11
  - gem install bundler
11
12
 
13
+ script:
14
+ - RAILS_ENV=test bundle exec rake spec && bundle exec codeclimate-test-reporter
15
+
12
16
  cache: bundler
13
17
  sudo: false
data/Appraisals CHANGED
@@ -5,3 +5,8 @@ end
5
5
  appraise "4.2" do
6
6
  gem "rails", "~> 4.2"
7
7
  end
8
+
9
+ appraise "5.0" do
10
+ gem "rails", "~> 5.0"
11
+ gem "sinatra", github: "sinatra/sinatra"
12
+ end
@@ -1,6 +1,28 @@
1
+ ## 2.2.0 (November 11th, 2016)
2
+
3
+ Features:
4
+
5
+ - Remove dependency on Redis::Namespace (@bschaeffer, #425)
6
+ - Make resetting on experiment change optional (@moggyboy, #430)
7
+ - Add ability to force alternative on dashboard (@ccallebs, #437)
8
+
9
+ Bugfixes:
10
+
11
+ - Fix variations reset across page loads for multiple=control and improve coverage (@Vasfed, #432)
12
+
13
+ Misc:
14
+
15
+ - Remove Explicit Return (@BradHudson, #441)
16
+ - Update Redis config docs (@bschaeffer, #422)
17
+ - Harden HTTP Basic snippet against timing attacks (@eliotsykes, #443)
18
+ - Removed a couple old ruby 1.8 hacks (@andrew, #456)
19
+ - Run tests on rails 5 (@andrew, #457)
20
+ - Fixed a few codeclimate warnings (@andrew, #458)
21
+ - Use codeclimate for test coverage (@andrew #455)
22
+
1
23
  ## 2.1.0 (August 8th, 2016)
2
24
 
3
- Features
25
+ Features:
4
26
 
5
27
  - Support REDIS_PROVIDER variable used in Heroku (@kartikluke, #426)
6
28
 
data/Gemfile CHANGED
@@ -3,3 +3,4 @@ source "https://rubygems.org"
3
3
  gemspec
4
4
 
5
5
  gem "appraisal"
6
+ gem "codeclimate-test-reporter"
data/README.md CHANGED
@@ -8,9 +8,8 @@ Split is designed to be hacker friendly, allowing for maximum customisation and
8
8
 
9
9
  [![Gem Version](https://badge.fury.io/rb/split.svg)](http://badge.fury.io/rb/split)
10
10
  [![Build Status](https://secure.travis-ci.org/splitrb/split.svg?branch=master)](http://travis-ci.org/splitrb/split)
11
- [![Code Climate](https://codeclimate.com/github/splitrb/split.svg)](https://codeclimate.com/github/splitrb/split)
12
- [![Coverage Status](http://img.shields.io/coveralls/splitrb/split.svg)](https://coveralls.io/r/splitrb/split)
13
-
11
+ [![Code Climate](https://codeclimate.com/github/splitrb/split/badges/gpa.svg)](https://codeclimate.com/github/splitrb/split)
12
+ [![Test Coverage](https://codeclimate.com/github/splitrb/split/badges/coverage.svg)](https://codeclimate.com/github/splitrb/split/coverage)
14
13
 
15
14
 
16
15
  # Backers
@@ -436,8 +435,22 @@ mount Split::Dashboard, at: 'split'
436
435
  You may want to password protect that page, you can do so with `Rack::Auth::Basic` (in your split initializer file)
437
436
 
438
437
  ```ruby
438
+ # Rails apps or apps that already depend on activesupport
439
+ Split::Dashboard.use Rack::Auth::Basic do |username, password|
440
+ # Protect against timing attacks:
441
+ # - Use & (do not use &&) so that it doesn't short circuit.
442
+ # - Use `variable_size_secure_compare` to stop length information leaking
443
+ ActiveSupport::SecurityUtils.variable_size_secure_compare(username, ENV["SPLIT_USERNAME"]) &
444
+ ActiveSupport::SecurityUtils.variable_size_secure_compare(password, ENV["SPLIT_PASSWORD"])
445
+ end
446
+
447
+ # Apps without activesupport
439
448
  Split::Dashboard.use Rack::Auth::Basic do |username, password|
440
- username == 'admin' && password == 'p4s5w0rd'
449
+ # Protect against timing attacks:
450
+ # - Use & (do not use &&) so that it doesn't short circuit.
451
+ # - Use digests to stop length information leaking
452
+ Rack::Utils.secure_compare(::Digest::SHA256.hexdigest(username), ::Digest::SHA256.hexdigest(ENV["SPLIT_USERNAME"])) &
453
+ Rack::Utils.secure_compare(::Digest::SHA256.hexdigest(password), ::Digest::SHA256.hexdigest(ENV["SPLIT_PASSWORD"]))
441
454
  end
442
455
  ```
443
456
 
@@ -469,11 +482,16 @@ Split.configure do |config|
469
482
  config.persistence = Split::Persistence::SessionAdapter
470
483
  #config.start_manually = false ## new test will have to be started manually from the admin panel. default false
471
484
  config.include_rails_helper = true
472
- config.redis_url = "custom.redis.url:6380"
485
+ config.redis = "redis://custom.redis.url:6380"
473
486
  end
474
487
  ```
475
488
 
476
- Split looks for the Redis host in the environment variable ```REDIS_URL``` then defaults to ```localhost:6379``` if not specified by configure block.
489
+ Split looks for the Redis host in the environment variable `REDIS_URL` then
490
+ defaults to `redis://localhost:6379` if not specified by configure block.
491
+
492
+ On platforms like Heroku, Split will use the value of `REDIS_PROVIDER` to
493
+ determine which env variable key to use when retrieving the host config. This
494
+ defaults to `REDIS_URL`.
477
495
 
478
496
  ### Filtering
479
497
 
@@ -694,7 +712,7 @@ Split has a `redis` setter which can be given a string or a Redis
694
712
  object. This means if you're already using Redis in your app, Split
695
713
  can re-use the existing connection.
696
714
 
697
- String: `Split.redis = 'localhost:6379'`
715
+ String: `Split.redis = 'redis://localhost:6379'`
698
716
 
699
717
  Redis: `Split.redis = $redis`
700
718
 
@@ -705,11 +723,11 @@ appropriately.
705
723
  Here's our `config/split.yml`:
706
724
 
707
725
  ```yml
708
- development: localhost:6379
709
- test: localhost:6379
710
- staging: redis1.example.com:6379
711
- fi: localhost:6379
712
- production: redis1.example.com:6379
726
+ development: redis://localhost:6379
727
+ test: redis://localhost:6379
728
+ staging: redis://redis1.example.com:6379
729
+ fi: redis://localhost:6379
730
+ production: redis://redis1.example.com:6379
713
731
  ```
714
732
 
715
733
  And our initializer:
@@ -725,18 +743,22 @@ If you're running multiple, separate instances of Split you may want
725
743
  to namespace the keyspaces so they do not overlap. This is not unlike
726
744
  the approach taken by many memcached clients.
727
745
 
728
- This feature is provided by the [redis-namespace](https://github.com/defunkt/redis-namespace) library, which
729
- Split uses by default to separate the keys it manages from other keys
730
- in your Redis server.
746
+ This feature can be provided by the [redis-namespace](https://github.com/defunkt/redis-namespace)
747
+ library. To configure Split to use `Redis::Namespace`, do the following:
731
748
 
732
- Simply use the `Split.redis.namespace` accessor:
749
+ 1. Add `redis-namespace` to your Gemfile:
733
750
 
734
- ```ruby
735
- Split.redis.namespace = "split:blog"
736
- ```
751
+ ```ruby
752
+ gem 'redis-namespace'
753
+ ```
754
+
755
+ 2. Configure `Split.redis` to use a `Redis::Namespace` instance (possible in an
756
+ intializer):
737
757
 
738
- We recommend sticking this in your initializer somewhere after Redis
739
- is configured.
758
+ ```ruby
759
+ redis = Redis.new(url: ENV['REDIS_URL']) # or whatever config you want
760
+ Split.redis = Redis::Namespace.new(:your_namespace, redis: redis)
761
+ ```
740
762
 
741
763
  ## Outside of a Web Session
742
764
 
@@ -3,6 +3,7 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "appraisal"
6
+ gem "codeclimate-test-reporter"
6
7
  gem "rails", "~> 4.1"
7
8
 
8
9
  gemspec :path => "../"
@@ -3,6 +3,7 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "appraisal"
6
+ gem "codeclimate-test-reporter"
6
7
  gem "rails", "~> 4.2"
7
8
 
8
9
  gemspec :path => "../"
@@ -0,0 +1,10 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "appraisal"
6
+ gem "codeclimate-test-reporter"
7
+ gem "rails", "~> 5.0"
8
+ gem "sinatra", :github => "sinatra/sinatra"
9
+
10
+ gemspec :path => "../"
@@ -11,6 +11,7 @@
11
11
  metric
12
12
  persistence
13
13
  encapsulated_helper
14
+ redis_interface
14
15
  trial
15
16
  user
16
17
  version
@@ -19,36 +20,27 @@
19
20
  end
20
21
 
21
22
  require 'split/engine' if defined?(Rails)
22
- require 'redis/namespace'
23
23
 
24
24
  module Split
25
25
  extend self
26
26
  attr_accessor :configuration
27
27
 
28
28
  # Accepts:
29
- # 1. A 'hostname:port' string
30
- # 2. A 'hostname:port:db' string (to select the Redis db)
31
- # 3. A 'hostname:port/namespace' string (to set the Redis namespace)
32
- # 4. A redis URL string 'redis://host:port'
33
- # 5. An instance of `Redis`, `Redis::Client`, `Redis::DistRedis`,
34
- # or `Redis::Namespace`.
29
+ # 1. A redis URL (valid for `Redis.new(url: url)`)
30
+ # 2. an options hash compatible with `Redis.new`
31
+ # 3. or a valid Redis instance (one that responds to `#smembers`). Likely,
32
+ # this will be an instance of either `Redis`, `Redis::Client`,
33
+ # `Redis::DistRedis`, or `Redis::Namespace`.
35
34
  def redis=(server)
36
- if server.respond_to? :split
37
- if server["redis://"]
38
- redis = Redis.connect(:url => server, :thread_safe => true)
39
- else
40
- server, namespace = server.split('/', 2)
41
- host, port, db = server.split(':')
42
- redis = Redis.new(:host => host, :port => port,
43
- :thread_safe => true, :db => db)
44
- end
45
- namespace ||= :split
46
-
47
- @redis = Redis::Namespace.new(namespace, :redis => redis)
48
- elsif server.respond_to? :namespace=
49
- @redis = server
35
+ @redis = if server.is_a?(String)
36
+ Redis.new(:url => server, :thread_safe => true)
37
+ elsif server.is_a?(Hash)
38
+ Redis.new(server.merge(:thread_safe => true))
39
+ elsif server.respond_to?(:smembers)
40
+ server
50
41
  else
51
- @redis = Redis::Namespace.new(:split, :redis => server)
42
+ raise ArgumentError,
43
+ "You must supply a url, options hash or valid Redis connection instance"
52
44
  end
53
45
  end
54
46
 
@@ -56,7 +48,7 @@ module Split
56
48
  # create a new one.
57
49
  def redis
58
50
  return @redis if @redis
59
- self.redis = self.configuration.redis_url
51
+ self.redis = self.configuration.redis
60
52
  self.redis
61
53
  end
62
54
 
@@ -34,7 +34,6 @@ module Split
34
34
  def p_winner(goal = nil)
35
35
  field = set_prob_field(goal)
36
36
  @p_winner = Split.redis.hget(key, field).to_f
37
- return @p_winner
38
37
  end
39
38
 
40
39
  def set_p_winner(prob, goal = nil)
@@ -15,6 +15,7 @@ module Split
15
15
  attr_accessor :algorithm
16
16
  attr_accessor :store_override
17
17
  attr_accessor :start_manually
18
+ attr_accessor :reset_manually
18
19
  attr_accessor :on_trial
19
20
  attr_accessor :on_trial_choose
20
21
  attr_accessor :on_trial_complete
@@ -24,7 +25,7 @@ module Split
24
25
  attr_accessor :on_before_experiment_delete
25
26
  attr_accessor :include_rails_helper
26
27
  attr_accessor :beta_probability_simulations
27
- attr_accessor :redis_url
28
+ attr_accessor :redis
28
29
 
29
30
  attr_reader :experiments
30
31
 
@@ -211,7 +212,17 @@ module Split
211
212
  @algorithm = Split::Algorithms::WeightedSample
212
213
  @include_rails_helper = true
213
214
  @beta_probability_simulations = 10000
214
- @redis_url = ENV.fetch(ENV.fetch('REDIS_PROVIDER', 'REDIS_URL'), 'localhost:6379')
215
+ @redis = ENV.fetch(ENV.fetch('REDIS_PROVIDER', 'REDIS_URL'), 'redis://localhost:6379')
216
+ end
217
+
218
+ def redis_url=(value)
219
+ warn '[DEPRECATED] `redis_url=` is deprecated in favor of `redis=`'
220
+ self.redis = value
221
+ end
222
+
223
+ def redis_url
224
+ warn '[DEPRECATED] `redis_url` is deprecated in favor of `redis`'
225
+ self.redis
215
226
  end
216
227
 
217
228
  private
@@ -30,6 +30,11 @@ module Split
30
30
  erb :index
31
31
  end
32
32
 
33
+ post '/force_alternative' do
34
+ Split::User.new(self)[params[:experiment]] = params[:alternative]
35
+ redirect url('/')
36
+ end
37
+
33
38
  post '/experiment' do
34
39
  @experiment = Split::ExperimentCatalog.find(params[:experiment])
35
40
  @alternative = Split::Alternative.new(params[:alternative], params[:experiment])
@@ -2,7 +2,7 @@ $(function() {
2
2
  $('#filter').on('keyup', function() {
3
3
  $input = $(this);
4
4
 
5
- if ($input.val() == '') {
5
+ if ($input.val() === '') {
6
6
  $('div.experiment').show();
7
7
  return false;
8
8
  }
@@ -21,7 +21,7 @@ $(function() {
21
21
 
22
22
  $('#toggle-active').on('click', function() {
23
23
  $button = $(this);
24
- if ($button.val() == 'Hide active') {
24
+ if ($button.val() === 'Hide active') {
25
25
  $button.val('Show active');
26
26
  } else {
27
27
  $button.val('Hide active');
@@ -32,7 +32,7 @@ $(function() {
32
32
 
33
33
  $('#toggle-completed').on('click', function() {
34
34
  $button = $(this);
35
- if ($button.val() == 'Hide completed') {
35
+ if ($button.val() === 'Hide completed') {
36
36
  $button.val('Show completed');
37
37
  } else {
38
38
  $button.val('Hide completed');
@@ -51,6 +51,10 @@
51
51
  <% if alternative.control? %>
52
52
  <em>control</em>
53
53
  <% end %>
54
+ <form action="<%= url('force_alternative') + '?experiment=' + experiment.name %>" method='post'>
55
+ <input type='hidden' name='alternative' value='<%= h alternative.name %>'>
56
+ <input type="submit" value="Force for current user" class="green">
57
+ </form>
54
58
  </td>
55
59
  <td><%= alternative.participant_count %></td>
56
60
  <td><%= alternative.unfinished_count %></td>
@@ -26,21 +26,8 @@ module Split
26
26
  end
27
27
  end
28
28
 
29
- def ab_test(*arguments)
30
- ret = split_context_shim.ab_test(*arguments)
31
- # TODO there must be a better way to pass a block straight
32
- # through to the original ab_test
33
- if block_given?
34
- if defined?(capture) # a block in a rails view
35
- block = Proc.new { yield(ret) }
36
- concat(capture(ret, &block))
37
- false
38
- else
39
- yield(ret)
40
- end
41
- else
42
- ret
43
- end
29
+ def ab_test(*arguments,&block)
30
+ split_context_shim.ab_test(*arguments,&block)
44
31
  end
45
32
 
46
33
  private
@@ -80,29 +80,15 @@ module Split
80
80
  validate!
81
81
 
82
82
  if new_record?
83
- Split.redis.sadd(:experiments, name)
84
83
  start unless Split.configuration.start_manually
85
- @alternatives.reverse.each {|a| Split.redis.lpush(name, a.name)}
86
- goals_collection.save
87
- save_metadata
88
- else
89
- existing_alternatives = load_alternatives_from_redis
90
- existing_goals = Split::GoalsCollection.new(@name).load_from_redis
91
- existing_metadata = load_metadata_from_redis
92
- unless existing_alternatives == @alternatives.map(&:name) && existing_goals == @goals && existing_metadata == @metadata
93
- reset
94
- @alternatives.each(&:delete)
95
- goals_collection.delete
96
- delete_metadata
97
- Split.redis.del(@name)
98
- @alternatives.reverse.each {|a| Split.redis.lpush(name, a.name)}
99
- goals_collection.save
100
- save_metadata
101
- end
84
+ elsif experiment_configuration_has_changed?
85
+ reset unless Split.configuration.reset_manually
102
86
  end
103
87
 
104
- Split.redis.hset(experiment_config_key, :resettable, resettable)
105
- Split.redis.hset(experiment_config_key, :algorithm, algorithm.to_s)
88
+ persist_experiment_configuration if new_record? || experiment_configuration_has_changed?
89
+
90
+ redis.hset(experiment_config_key, :resettable, resettable)
91
+ redis.hset(experiment_config_key, :algorithm, algorithm.to_s)
106
92
  self
107
93
  end
108
94
 
@@ -115,7 +101,7 @@ module Split
115
101
  end
116
102
 
117
103
  def new_record?
118
- !Split.redis.exists(name)
104
+ !redis.exists(name)
119
105
  end
120
106
 
121
107
  def ==(obj)
@@ -149,8 +135,9 @@ module Split
149
135
  end
150
136
 
151
137
  def winner
152
- if w = Split.redis.hget(:experiment_winner, name)
153
- Split::Alternative.new(w, name)
138
+ experiment_winner = redis.hget(:experiment_winner, name)
139
+ if experiment_winner
140
+ Split::Alternative.new(experiment_winner, name)
154
141
  else
155
142
  nil
156
143
  end
@@ -161,7 +148,7 @@ module Split
161
148
  end
162
149
 
163
150
  def winner=(winner_name)
164
- Split.redis.hset(:experiment_winner, name, winner_name.to_s)
151
+ redis.hset(:experiment_winner, name, winner_name.to_s)
165
152
  end
166
153
 
167
154
  def participant_count
@@ -173,21 +160,21 @@ module Split
173
160
  end
174
161
 
175
162
  def reset_winner
176
- Split.redis.hdel(:experiment_winner, name)
163
+ redis.hdel(:experiment_winner, name)
177
164
  end
178
165
 
179
166
  def start
180
- Split.redis.hset(:experiment_start_times, @name, Time.now.to_i)
167
+ redis.hset(:experiment_start_times, @name, Time.now.to_i)
181
168
  end
182
169
 
183
170
  def start_time
184
- t = Split.redis.hget(:experiment_start_times, @name)
171
+ t = redis.hget(:experiment_start_times, @name)
185
172
  if t
186
173
  # Check if stored time is an integer
187
174
  if t =~ /^[-+]?[0-9]+$/
188
- t = Time.at(t.to_i)
175
+ Time.at(t.to_i)
189
176
  else
190
- t = Time.parse(t)
177
+ Time.parse(t)
191
178
  end
192
179
  end
193
180
  end
@@ -205,11 +192,11 @@ module Split
205
192
  end
206
193
 
207
194
  def version
208
- @version ||= (Split.redis.get("#{name.to_s}:version").to_i || 0)
195
+ @version ||= (redis.get("#{name}:version").to_i || 0)
209
196
  end
210
197
 
211
198
  def increment_version
212
- @version = Split.redis.incr("#{name}:version")
199
+ @version = redis.incr("#{name}:version")
213
200
  end
214
201
 
215
202
  def key
@@ -247,24 +234,21 @@ module Split
247
234
  def delete
248
235
  Split.configuration.on_before_experiment_delete.call(self)
249
236
  if Split.configuration.start_manually
250
- Split.redis.hdel(:experiment_start_times, @name)
237
+ redis.hdel(:experiment_start_times, @name)
251
238
  end
252
- alternatives.each(&:delete)
253
239
  reset_winner
254
- Split.redis.srem(:experiments, name)
255
- Split.redis.del(name)
256
- goals_collection.delete
257
- delete_metadata
240
+ redis.srem(:experiments, name)
241
+ remove_experiment_configuration
258
242
  Split.configuration.on_experiment_delete.call(self)
259
243
  increment_version
260
244
  end
261
245
 
262
246
  def delete_metadata
263
- Split.redis.del(metadata_key)
247
+ redis.del(metadata_key)
264
248
  end
265
249
 
266
250
  def load_from_redis
267
- exp_config = Split.redis.hgetall(experiment_config_key)
251
+ exp_config = redis.hgetall(experiment_config_key)
268
252
 
269
253
  options = {
270
254
  resettable: exp_config['resettable'],
@@ -297,8 +281,6 @@ module Split
297
281
  end
298
282
 
299
283
  def estimate_winning_alternative(goal = nil)
300
- # TODO - refactor out functionality to work with and without goals
301
-
302
284
  # initialize a hash of beta distributions based on the alternatives' conversion rates
303
285
  beta_params = calc_beta_params(goal)
304
286
 
@@ -395,11 +377,11 @@ module Split
395
377
  end
396
378
 
397
379
  def calc_time=(time)
398
- Split.redis.hset(experiment_config_key, :calc_time, time)
380
+ redis.hset(experiment_config_key, :calc_time, time)
399
381
  end
400
382
 
401
383
  def calc_time
402
- Split.redis.hget(experiment_config_key, :calc_time).to_i
384
+ redis.hget(experiment_config_key, :calc_time).to_i
403
385
  end
404
386
 
405
387
  def jstring(goal = nil)
@@ -418,11 +400,11 @@ module Split
418
400
  end
419
401
 
420
402
  def load_metadata_from_configuration
421
- metadata = Split.configuration.experiment_for(@name)[:metadata]
403
+ Split.configuration.experiment_for(@name)[:metadata]
422
404
  end
423
405
 
424
406
  def load_metadata_from_redis
425
- meta = Split.redis.get(metadata_key)
407
+ meta = redis.get(metadata_key)
426
408
  JSON.parse(meta) unless meta.nil?
427
409
  end
428
410
 
@@ -437,22 +419,49 @@ module Split
437
419
  end
438
420
 
439
421
  def load_alternatives_from_redis
440
- case Split.redis.type(@name)
422
+ case redis.type(@name)
441
423
  when 'set' # convert legacy sets to lists
442
- alts = Split.redis.smembers(@name)
443
- Split.redis.del(@name)
444
- alts.reverse.each {|a| Split.redis.lpush(@name, a) }
445
- Split.redis.lrange(@name, 0, -1)
424
+ alts = redis.smembers(@name)
425
+ redis.del(@name)
426
+ alts.reverse.each {|a| redis.lpush(@name, a) }
427
+ redis.lrange(@name, 0, -1)
446
428
  else
447
- Split.redis.lrange(@name, 0, -1)
429
+ redis.lrange(@name, 0, -1)
448
430
  end
449
431
  end
450
432
 
451
- def save_metadata
452
- Split.redis.set(metadata_key, @metadata.to_json) unless @metadata.nil?
433
+ private
434
+
435
+ def redis
436
+ Split.redis
453
437
  end
454
438
 
455
- private
439
+ def redis_interface
440
+ RedisInterface.new
441
+ end
442
+
443
+ def persist_experiment_configuration
444
+ redis_interface.add_to_set(:experiments, name)
445
+ redis_interface.persist_list(name, @alternatives.map(&:name))
446
+ goals_collection.save
447
+ redis.set(metadata_key, @metadata.to_json) unless @metadata.nil?
448
+ end
449
+
450
+ def remove_experiment_configuration
451
+ @alternatives.each(&:delete)
452
+ goals_collection.delete
453
+ delete_metadata
454
+ redis.del(@name)
455
+ end
456
+
457
+ def experiment_configuration_has_changed?
458
+ existing_alternatives = load_alternatives_from_redis
459
+ existing_goals = Split::GoalsCollection.new(@name).load_from_redis
460
+ existing_metadata = load_metadata_from_redis
461
+ existing_alternatives != @alternatives.map(&:name) ||
462
+ existing_goals != @goals ||
463
+ existing_metadata != @metadata
464
+ end
456
465
 
457
466
  def goals_collection
458
467
  Split::GoalsCollection.new(@name, @goals)