verdict 0.2.0 → 0.2.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.travis.yml +1 -0
- data/Gemfile +4 -0
- data/README.md +56 -33
- data/lib/verdict.rb +2 -1
- data/lib/verdict/experiment.rb +15 -7
- data/lib/verdict/segmenters.rb +4 -0
- data/lib/verdict/segmenters/base_segmenter.rb +84 -0
- data/lib/verdict/segmenters/fixed_percentage_segmenter.rb +56 -0
- data/lib/verdict/segmenters/rollout_segmenter.rb +55 -0
- data/lib/verdict/segmenters/static_segmenter.rb +27 -0
- data/lib/verdict/storage.rb +3 -138
- data/lib/verdict/storage/memory_storage.rb +40 -0
- data/lib/verdict/storage/mock_storage.rb +38 -0
- data/lib/verdict/storage/redis_storage.rb +62 -0
- data/lib/verdict/tasks.rake +8 -8
- data/lib/verdict/version.rb +1 -1
- data/test/assignment_test.rb +4 -4
- data/test/conversion_test.rb +1 -1
- data/test/event_logger_test.rb +1 -1
- data/test/experiment_test.rb +12 -3
- data/test/experiments_repository_test.rb +1 -1
- data/test/group_test.rb +2 -2
- data/test/metadata_test.rb +1 -1
- data/test/{fixed_percentage_segmenter_test.rb → segmenters/fixed_percentage_segmenter_test.rb} +11 -11
- data/test/{rollout_segmenter_test.rb → segmenters/rollout_segmenter_test.rb} +2 -2
- data/test/{static_segmenter_test.rb → segmenters/static_segmenter_test.rb} +2 -2
- data/test/{memory_subject_storage_test.rb → storage/memory_subject_storage_test.rb} +1 -1
- data/test/{redis_subject_storage_test.rb → storage/redis_subject_storage_test.rb} +2 -2
- data/test/test_helper.rb +8 -0
- data/verdict.gemspec +6 -6
- metadata +24 -19
- data/lib/verdict/fixed_percentage_segmenter.rb +0 -52
- data/lib/verdict/rollout_segmenter.rb +0 -53
- data/lib/verdict/segmenter.rb +0 -87
- data/lib/verdict/static_segmenter.rb +0 -24
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 969beb7809f021e2f73e70128dd7565081824240
|
4
|
+
data.tar.gz: 1abbcd8a1c3b5d0776d3649049d8b34cada22cf9
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ae0034fd9af434c7fec7be4dd0aabc427dca17c7990e150941251209323cbad98522d3057dd26abc6e1fffcb739e881d5cacc54c636b798cda322c1cdc0b056a
|
7
|
+
data.tar.gz: f1e8ef2ca4f3ddc3995b72b33b3fa3dd2259c4520c8e691362a33e1ccfab9ce42aff6bc336b7723c1505db6a86d3ce4bcc90df07376201fbea208486ab3ef4f0
|
data/.travis.yml
CHANGED
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -1,12 +1,14 @@
|
|
1
|
-
# Verdict
|
1
|
+
# Verdict
|
2
|
+
|
3
|
+
[![Build Status](https://travis-ci.org/Shopify/verdict.png)](https://travis-ci.org/Shopify/verdict)
|
4
|
+
[![Code Climate](https://codeclimate.com/github/Shopify/verdict.png)](https://codeclimate.com/github/Shopify/verdict)
|
2
5
|
|
3
6
|
This library allows you to define and use experiments in your application.
|
4
7
|
|
5
|
-
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
of results. That should happen elsewhere, e.g. in a data warehouse environment.
|
8
|
+
- It can be used in any Ruby application, and comes with a `Railtie` to make integrating it with a Rails app easy.
|
9
|
+
- It handles consistently assigning subjects to experiment groups, and storing/logging these assignments for analysis.
|
10
|
+
|
11
|
+
__*This library doesn't do any analysis of results. That should happen elsewhere, e.g. in a data warehouse environment.*__
|
10
12
|
|
11
13
|
|
12
14
|
## Installation
|
@@ -15,12 +17,13 @@ Add this line to your application's Gemfile, and run `bundle install`:
|
|
15
17
|
|
16
18
|
gem 'verdict'
|
17
19
|
|
20
|
+
If you're using Rails, the Railtie will handle setting the logger to `Rails.logger` and the experiments directory to `app/experiments`. It will also load the rake tasks for you (run `bundle exec rake -T | grep experiments:` for options).
|
21
|
+
|
18
22
|
## Usage
|
19
23
|
|
20
|
-
|
21
|
-
in order consistently modify application behaviour based on an object's unique key.
|
24
|
+
The `Verdict::Experiment` class is used to create an experiment, define control and experiment groups, and to qualify subjects.
|
22
25
|
|
23
|
-
|
26
|
+
You define an experiment like so:
|
24
27
|
|
25
28
|
``` ruby
|
26
29
|
Verdict::Experiment.define :my_experiment do
|
@@ -39,46 +42,66 @@ Verdict::Experiment.define :my_experiment do
|
|
39
42
|
end
|
40
43
|
```
|
41
44
|
|
42
|
-
Usually you want to place this in a file called **my_experiment.rb** in the
|
43
|
-
**/app/experiments** folder.
|
44
|
-
|
45
|
-
instead.
|
45
|
+
Usually you'll want to place this in a file called **my_experiment.rb** in the
|
46
|
+
**/app/experiments** folder.
|
47
|
+
|
48
|
+
_We recommend that you subclass `Verdict::Experiment` to set some default options for your app's environment, and call `define` on that class instead._
|
46
49
|
|
47
|
-
|
50
|
+
### Determining a Subject's Group
|
51
|
+
|
52
|
+
At the relevant point in your application, you can check the group that a particular subject belongs to using the `switch` method.
|
53
|
+
|
54
|
+
You'll need to pass along the subject (think User, Product or any other Model class) as well as any context to be used for qualifying the subject.
|
48
55
|
|
49
56
|
``` ruby
|
50
|
-
context = { ... } # anything you want to pass along to the qualify block.
|
57
|
+
context = { ... } # anything you want to pass along to the qualify block.
|
51
58
|
case Verdict['my experiment'].switch(shop, context)
|
52
59
|
when :test
|
53
60
|
# Handle test group
|
54
61
|
when :control
|
55
62
|
# Handle control group
|
56
|
-
else
|
57
|
-
# Handle unqualified subjects.
|
63
|
+
else
|
64
|
+
# Handle unqualified subjects.
|
58
65
|
end
|
59
66
|
```
|
60
67
|
|
61
|
-
## Storage
|
68
|
+
## Storage
|
69
|
+
|
70
|
+
Verdict uses a very simple interface for storing experiment assignments. Out of the box, Verdict ships with storage providers for:
|
71
|
+
|
72
|
+
* Memory
|
73
|
+
* Redis
|
74
|
+
|
75
|
+
You can set up storage for your experiment by calling the `storage` method with
|
76
|
+
an object that responds to the following methods:
|
77
|
+
|
78
|
+
* `store_assignment(assignment)`
|
79
|
+
* `retrieve_assignment(experiment, subject_identifier)`
|
80
|
+
* `remove_assignment(experiment, subject_identifier)`
|
81
|
+
* `clear_experiment(experiment)`
|
82
|
+
* `retrieve_start_timestamp(experiment)`
|
83
|
+
* `store_start_timestamp(experiment, timestamp)`
|
84
|
+
|
85
|
+
Regarding the method signatures above, `experiment` is the Experiment instance, `subject_identifier` is a string that uniquely identifies the subject, and `assignment` is a `Verdict::Assignment` instance.
|
86
|
+
|
87
|
+
By default it will use `subject.id.to_s` as `subject_identifier`, but you can change that by overriding `def subject_identifier(subject)` on the experiment.
|
88
|
+
|
89
|
+
Storage providers simply store subject assignments and require quick lookups of subject identifiers. They allow for complex (high CPU) assignments, and for assignments that might not always put the same subject in the same group by storing the assignment for later use.
|
90
|
+
|
91
|
+
Storage providers are intended for operational use and should not be used for data analysis. For data analysis, you should use the logger.
|
92
|
+
|
93
|
+
For more details about these methods, check out the source code for [Verdict::Storage::MockStorage](lib/verdict/storage/mock.rb)
|
94
|
+
|
95
|
+
## Logging
|
62
96
|
|
63
|
-
|
64
|
-
a development-only memory store, it doesn't include any storage models.
|
97
|
+
Every assignment will be logged to `Verdict.logger`. For rails apps, this logger will be automatically set to `Rails.logger` so experiment assignments will show up in your Rails log.
|
65
98
|
|
66
|
-
You can
|
67
|
-
an object that responds to the following tho methods:
|
99
|
+
You can override the logging by overriding the `def log_assignment(assignment)` method on the experiment.
|
68
100
|
|
69
|
-
-
|
70
|
-
- `def store_assignment(assignment)`
|
101
|
+
Logging (as opposed to storage) should be used for data analysis. The logger requires a write-only / forward-only stream to write to, e.g. a log file, Kafka, or an insert-only database table.
|
71
102
|
|
72
|
-
|
73
|
-
string that uniquely identifies the subject, and `assignment` is an
|
74
|
-
`Verdict::Assignment` instance. By default it will use `subject.id.to_s` as
|
75
|
-
`subject_identifier`, but you can change that by overriding the
|
76
|
-
`def subject_identifier(subject)` method on the experiment.
|
103
|
+
It's possible to run an experiment without defining any storage, though this comes with several drawbacks. Logging on the other hand is required in order to analyze the results.
|
77
104
|
|
78
|
-
The library will also log every assignment to `Verdict.logger`. The Railtie
|
79
|
-
sets `Verdict.logger` to `Rails.logger`, so experiment assignments will show
|
80
|
-
up in your Rails log. You can override the logging by overriding the
|
81
|
-
`def log_assignment(assignment)` method on the experiment.
|
82
105
|
|
83
106
|
## Contributing
|
84
107
|
|
data/lib/verdict.rb
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
require 'logger'
|
2
|
+
require 'digest/md5'
|
2
3
|
|
3
4
|
module Verdict
|
4
5
|
extend self
|
@@ -46,7 +47,7 @@ require "verdict/experiment"
|
|
46
47
|
require "verdict/group"
|
47
48
|
require "verdict/assignment"
|
48
49
|
require "verdict/conversion"
|
49
|
-
require "verdict/
|
50
|
+
require "verdict/segmenters"
|
50
51
|
require "verdict/storage"
|
51
52
|
require "verdict/event_logger"
|
52
53
|
|
data/lib/verdict/experiment.rb
CHANGED
@@ -16,7 +16,7 @@ class Verdict::Experiment
|
|
16
16
|
options = default_options.merge(options)
|
17
17
|
@qualifier = options[:qualifier]
|
18
18
|
@event_logger = options[:event_logger] || Verdict::EventLogger.new(Verdict.default_logger)
|
19
|
-
@subject_storage = options[:storage] ||
|
19
|
+
@subject_storage = storage(options[:storage] || :memory)
|
20
20
|
@store_unqualified = options[:store_unqualified]
|
21
21
|
@segmenter = options[:segmenter]
|
22
22
|
@subject_type = options[:subject_type]
|
@@ -25,6 +25,7 @@ class Verdict::Experiment
|
|
25
25
|
instance_eval(&block) if block_given?
|
26
26
|
end
|
27
27
|
|
28
|
+
|
28
29
|
def subject_type(type = nil)
|
29
30
|
return @subject_type if type.nil?
|
30
31
|
@subject_type = type
|
@@ -38,7 +39,7 @@ class Verdict::Experiment
|
|
38
39
|
segmenter.groups[handle.to_s]
|
39
40
|
end
|
40
41
|
|
41
|
-
def groups(segmenter_class = Verdict::FixedPercentageSegmenter, &block)
|
42
|
+
def groups(segmenter_class = Verdict::Segmenters::FixedPercentageSegmenter, &block)
|
42
43
|
return segmenter.groups unless block_given?
|
43
44
|
@segmenter ||= segmenter_class.new(self)
|
44
45
|
@segmenter.instance_eval(&block)
|
@@ -47,7 +48,7 @@ class Verdict::Experiment
|
|
47
48
|
end
|
48
49
|
|
49
50
|
def rollout_percentage(percentage, rollout_group_name = :enabled)
|
50
|
-
groups(Verdict::RolloutSegmenter) do
|
51
|
+
groups(Verdict::Segmenters::RolloutSegmenter) do
|
51
52
|
group rollout_group_name, percentage
|
52
53
|
end
|
53
54
|
end
|
@@ -56,9 +57,16 @@ class Verdict::Experiment
|
|
56
57
|
@qualifier = block
|
57
58
|
end
|
58
59
|
|
59
|
-
def storage(subject_storage, options = {})
|
60
|
+
def storage(subject_storage = nil, options = {})
|
61
|
+
return @subject_storage if subject_storage.nil?
|
62
|
+
|
60
63
|
@store_unqualified = options[:store_unqualified] if options.has_key?(:store_unqualified)
|
61
|
-
@subject_storage = subject_storage
|
64
|
+
@subject_storage = case subject_storage
|
65
|
+
when :memory; Verdict::Storage::MemoryStorage.new
|
66
|
+
when :none; Verdict::Storage::MockStorage.new
|
67
|
+
when Class; subject_storage.new
|
68
|
+
else subject_storage
|
69
|
+
end
|
62
70
|
end
|
63
71
|
|
64
72
|
def segmenter
|
@@ -184,7 +192,7 @@ class Verdict::Experiment
|
|
184
192
|
|
185
193
|
def to_json(options = {})
|
186
194
|
as_json(options).to_json
|
187
|
-
end
|
195
|
+
end
|
188
196
|
|
189
197
|
def fetch_subject(subject_identifier)
|
190
198
|
raise NotImplementedError, "Fetching subjects based in identifier is not implemented for eperiment @{handle.inspect}."
|
@@ -221,7 +229,7 @@ class Verdict::Experiment
|
|
221
229
|
return previous_assignment unless previous_assignment.nil?
|
222
230
|
group = segmenter.assign(subject_identifier, subject, context)
|
223
231
|
subject_assignment(subject_identifier, group, nil, group.nil?)
|
224
|
-
else
|
232
|
+
else
|
225
233
|
subject_assignment(subject_identifier, nil, nil)
|
226
234
|
end
|
227
235
|
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
module Verdict
|
2
|
+
module Segmenters
|
3
|
+
# Base class of all segmenters.
|
4
|
+
#
|
5
|
+
# The segmenter is responsible for assigning subjects to groups. You can
|
6
|
+
# implement any assignment strategy you like by subclassing this class and
|
7
|
+
# using it in your experiment.
|
8
|
+
#
|
9
|
+
# - You should implement the register_group method for the experiment definition DSL
|
10
|
+
# to make the system aware of the groups that the segmenter could return.
|
11
|
+
# - The verify! method is called after all the groups have been defined, so it can
|
12
|
+
# detect internal inconsistencies in the group definitions.
|
13
|
+
# - The assign method is where your assignment magic lives.
|
14
|
+
class BaseSegmenter
|
15
|
+
# The experiment to which this segmenter is associated
|
16
|
+
attr_reader :experiment
|
17
|
+
|
18
|
+
# A hash of the groups that are defined in this experiment, indexed by their
|
19
|
+
# handle. The assign method should return one of the groups in this hash
|
20
|
+
attr_reader :groups
|
21
|
+
|
22
|
+
def initialize(experiment)
|
23
|
+
@experiment = experiment
|
24
|
+
@groups = {}
|
25
|
+
end
|
26
|
+
|
27
|
+
# DSL method to register a group. It calls the register_group method of the
|
28
|
+
# segmenter implementation
|
29
|
+
def group(handle, *args, &block)
|
30
|
+
group = register_group(handle, *args)
|
31
|
+
@groups[group.handle] = group
|
32
|
+
group.instance_eval(&block) if block_given?
|
33
|
+
end
|
34
|
+
|
35
|
+
# The group method is called from the experiment definition DSL.
|
36
|
+
# It should register a new group to the segmenter, with the given handle.
|
37
|
+
#
|
38
|
+
# - The handle parameter is a symbol that uniquely identifies the group within
|
39
|
+
# this experiment.
|
40
|
+
# - The return value of this method should be a Verdict::Group instance.
|
41
|
+
def register_group(handle, *args)
|
42
|
+
raise NotImplementedError
|
43
|
+
end
|
44
|
+
|
45
|
+
# The verify! method is called after all the groups have been defined in the
|
46
|
+
# experiment definition DSL. You can run any consistency checks in this method,
|
47
|
+
# and if anything is off, you can raise a Verdict::SegmentationError to
|
48
|
+
# signify the problem.
|
49
|
+
def verify!
|
50
|
+
# noop by default
|
51
|
+
end
|
52
|
+
|
53
|
+
# The assign method is called to assign a subject to one of the groups that have been defined
|
54
|
+
# in the segmenter implementation.
|
55
|
+
#
|
56
|
+
# - The identifier parameter is a string that uniquely identifies the subject.
|
57
|
+
# - The subject paramater is the subject instance that was passed to the framework,
|
58
|
+
# when the application code calls Experiment#assign or Experiment#switch.
|
59
|
+
# - The context parameter is an object that was passed to the framework, you can use this
|
60
|
+
# object any way you like in your segmenting logic.
|
61
|
+
#
|
62
|
+
# This method should return the Verdict::Group instance to which the subject should be assigned.
|
63
|
+
# This instance should be one of the group instance that was registered in the definition DSL.
|
64
|
+
def assign(identifier, subject, context)
|
65
|
+
raise NotImplementedError
|
66
|
+
end
|
67
|
+
|
68
|
+
|
69
|
+
# This method is called whenever a subjects converts to a goal, i.e., when Experiment#convert
|
70
|
+
# is called. You can use this to implement a feedback loop in your segmenter.
|
71
|
+
#
|
72
|
+
# - The identifier parameter is a string that uniquely identifies the subject.
|
73
|
+
# - The subject paramater is the subject instance that was passed to the framework,
|
74
|
+
# when the application code calls Experiment#assign or Experiment#switch.
|
75
|
+
# - The conversion parameter is a Verdict::Conversion instance that describes what
|
76
|
+
# goal the subject converted to.
|
77
|
+
#
|
78
|
+
# The return value of this method is not used.
|
79
|
+
def conversion_feedback(identifier, subject, conversion)
|
80
|
+
# noop by default
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
module Verdict
|
2
|
+
module Segmenters
|
3
|
+
class FixedPercentageSegmenter < BaseSegmenter
|
4
|
+
|
5
|
+
def initialize(experiment)
|
6
|
+
super
|
7
|
+
@total_percentage_segmented = 0
|
8
|
+
end
|
9
|
+
|
10
|
+
def verify!
|
11
|
+
raise Verdict::SegmentationError, "Should segment exactly 100% of the cases, but segments add up to #{@total_percentage_segmented}%." if @total_percentage_segmented != 100
|
12
|
+
end
|
13
|
+
|
14
|
+
def register_group(handle, size)
|
15
|
+
percentage = size.kind_of?(Hash) && size[:percentage] ? size[:percentage] : size
|
16
|
+
n = case percentage
|
17
|
+
when :rest; 100 - @total_percentage_segmented
|
18
|
+
when :half; 50
|
19
|
+
when Integer; percentage
|
20
|
+
else Integer(percentage)
|
21
|
+
end
|
22
|
+
|
23
|
+
group = Group.new(experiment, handle, @total_percentage_segmented ... (@total_percentage_segmented + n))
|
24
|
+
@total_percentage_segmented += n
|
25
|
+
return group
|
26
|
+
end
|
27
|
+
|
28
|
+
def assign(identifier, subject, context)
|
29
|
+
percentile = Digest::MD5.hexdigest("#{@experiment.handle}#{identifier}").to_i(16) % 100
|
30
|
+
groups.values.find { |group| group.percentile_range.include?(percentile) }
|
31
|
+
end
|
32
|
+
|
33
|
+
class Group < Verdict::Group
|
34
|
+
|
35
|
+
attr_reader :percentile_range
|
36
|
+
|
37
|
+
def initialize(experiment, handle, percentile_range)
|
38
|
+
super(experiment, handle)
|
39
|
+
@percentile_range = percentile_range
|
40
|
+
end
|
41
|
+
|
42
|
+
def percentage_size
|
43
|
+
percentile_range.end - percentile_range.begin
|
44
|
+
end
|
45
|
+
|
46
|
+
def to_s
|
47
|
+
"#{handle} (#{percentage_size}%)"
|
48
|
+
end
|
49
|
+
|
50
|
+
def as_json(options = {})
|
51
|
+
super(options).merge(percentage: percentage_size)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
module Verdict
|
2
|
+
module Segmenters
|
3
|
+
class RolloutSegmenter < BaseSegmenter
|
4
|
+
|
5
|
+
def initialize(experiment)
|
6
|
+
super
|
7
|
+
@total_percentage_segmented = 0
|
8
|
+
end
|
9
|
+
|
10
|
+
def verify!
|
11
|
+
raise Verdict::SegmentationError, "Should segment less than 100% of the cases, but segments add up to #{@total_percentage_segmented}%." if @total_percentage_segmented >= 100
|
12
|
+
end
|
13
|
+
|
14
|
+
def register_group(handle, size)
|
15
|
+
percentage = size.kind_of?(Hash) && size[:percentage] ? size[:percentage] : size
|
16
|
+
n = case percentage
|
17
|
+
when :rest; 100 - @total_percentage_segmented
|
18
|
+
when :half; 50
|
19
|
+
when Integer; percentage
|
20
|
+
else Integer(percentage)
|
21
|
+
end
|
22
|
+
|
23
|
+
group = Group.new(experiment, handle, @total_percentage_segmented ... (@total_percentage_segmented + n))
|
24
|
+
@total_percentage_segmented += n
|
25
|
+
return group
|
26
|
+
end
|
27
|
+
|
28
|
+
def assign(identifier, subject, context)
|
29
|
+
percentile = Digest::MD5.hexdigest("#{@experiment.handle}#{identifier}").to_i(16) % 100
|
30
|
+
groups.values.find { |group| group.percentile_range.include?(percentile) }
|
31
|
+
end
|
32
|
+
|
33
|
+
class Group < Verdict::Group
|
34
|
+
attr_reader :percentile_range
|
35
|
+
|
36
|
+
def initialize(experiment, handle, percentile_range)
|
37
|
+
super(experiment, handle)
|
38
|
+
@percentile_range = percentile_range
|
39
|
+
end
|
40
|
+
|
41
|
+
def percentage_size
|
42
|
+
percentile_range.end - percentile_range.begin
|
43
|
+
end
|
44
|
+
|
45
|
+
def to_s
|
46
|
+
"#{handle} (#{percentage_size}%)"
|
47
|
+
end
|
48
|
+
|
49
|
+
def as_json(options = {})
|
50
|
+
super(options).merge(percentage: percentage_size)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Verdict
|
2
|
+
module Segmenters
|
3
|
+
class StaticSegmenter < BaseSegmenter
|
4
|
+
|
5
|
+
def register_group(handle, subject_identifiers)
|
6
|
+
Group.new(experiment, handle, subject_identifiers)
|
7
|
+
end
|
8
|
+
|
9
|
+
def assign(identifier, subject, context)
|
10
|
+
groups.values.find { |group| group.subject_identifiers.include?(identifier) }
|
11
|
+
end
|
12
|
+
|
13
|
+
class Group < Verdict::Group
|
14
|
+
attr_reader :subject_identifiers
|
15
|
+
|
16
|
+
def initialize(experiment, handle, subject_identifiers)
|
17
|
+
super(experiment, handle)
|
18
|
+
@subject_identifiers = subject_identifiers
|
19
|
+
end
|
20
|
+
|
21
|
+
def as_json(options = {})
|
22
|
+
super(options).merge(subject_identifiers: subject_identifiers)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|