scientist 0.0.0 → 0.0.1
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 +4 -4
- data/CLA.md +54 -0
- data/CONTRIBUTING.md +3 -0
- data/Gemfile +0 -1
- data/README.md +413 -4
- data/lib/scientist/default.rb +21 -0
- data/lib/scientist/errors.rb +38 -0
- data/lib/scientist/experiment.rb +233 -0
- data/lib/scientist/observation.rb +92 -0
- data/lib/scientist/result.rb +77 -0
- data/lib/scientist/version.rb +1 -1
- data/lib/scientist.rb +39 -2
- data/scientist.gemspec +5 -3
- data/script/test +4 -1
- data/test/scientist/default_test.rb +23 -0
- data/test/scientist/experiment_test.rb +398 -0
- data/test/scientist/observation_test.rb +93 -0
- data/test/scientist/result_test.rb +111 -0
- data/test/scientist_test.rb +42 -4
- metadata +22 -21
@@ -0,0 +1,233 @@
|
|
1
|
+
# This mixin provides shared behavior for experiments. Includers must implement
|
2
|
+
# `enabled?` and `publish(result)`.
|
3
|
+
#
|
4
|
+
# Override Scientist::Experiment.new to set your own class which includes and
|
5
|
+
# implements Scientist::Experiment's interface.
|
6
|
+
module Scientist::Experiment
|
7
|
+
|
8
|
+
# Create a new instance of a class that implements the Scientist::Experiment
|
9
|
+
# interface.
|
10
|
+
#
|
11
|
+
# Override this method directly to change the default implementation.
|
12
|
+
def self.new(name)
|
13
|
+
Scientist::Default.new(name)
|
14
|
+
end
|
15
|
+
|
16
|
+
# A mismatch, raised when raise_on_mismatches is enabled.
|
17
|
+
class MismatchError < StandardError
|
18
|
+
def initialize(name, result)
|
19
|
+
super "#{name}: control #{result.control.inspect}, candidates #{result.candidates.map(&:inspect)}"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
module RaiseOnMismatch
|
24
|
+
# Set this flag to raise on experiment mismatches.
|
25
|
+
#
|
26
|
+
# This causes all science mismatches to raise a MismatchError. This is
|
27
|
+
# intended for test environments and should not be enabled in a production
|
28
|
+
# environment.
|
29
|
+
#
|
30
|
+
# bool - true/false - whether to raise when the control and candidate mismatch.
|
31
|
+
def raise_on_mismatches=(bool)
|
32
|
+
@raise_on_mismatches = bool
|
33
|
+
end
|
34
|
+
|
35
|
+
# Whether or not to raise a mismatch error when a mismatch occurs.
|
36
|
+
def raise_on_mismatches?
|
37
|
+
@raise_on_mismatches
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.included(base)
|
42
|
+
base.extend RaiseOnMismatch
|
43
|
+
end
|
44
|
+
|
45
|
+
# A Hash of behavior blocks, keyed by String name. Register behavior blocks
|
46
|
+
# with the `try` and `use` methods.
|
47
|
+
def behaviors
|
48
|
+
@_scientist_behaviors ||= {}
|
49
|
+
end
|
50
|
+
|
51
|
+
# A block to clean an observed value for publishing or storing.
|
52
|
+
#
|
53
|
+
# The block takes one argument, the observed value which will be cleaned.
|
54
|
+
#
|
55
|
+
# Returns the configured block.
|
56
|
+
def clean(&block)
|
57
|
+
@_scientist_cleaner = block
|
58
|
+
end
|
59
|
+
|
60
|
+
# Internal: Clean a value with the configured clean block, or return the value
|
61
|
+
# if no clean block is configured.
|
62
|
+
#
|
63
|
+
# Rescues and reports exceptions in the clean block if they occur.
|
64
|
+
def clean_value(value)
|
65
|
+
if @_scientist_cleaner
|
66
|
+
@_scientist_cleaner.call value
|
67
|
+
else
|
68
|
+
value
|
69
|
+
end
|
70
|
+
rescue StandardError => ex
|
71
|
+
raised :clean, ex
|
72
|
+
value
|
73
|
+
end
|
74
|
+
|
75
|
+
# A block which compares two experimental values.
|
76
|
+
#
|
77
|
+
# The block must take two arguments, the control value and a candidate value,
|
78
|
+
# and return true or false.
|
79
|
+
#
|
80
|
+
# Returns the block.
|
81
|
+
def compare(*args, &block)
|
82
|
+
@_scientist_comparator = block
|
83
|
+
end
|
84
|
+
|
85
|
+
# A Symbol-keyed Hash of extra experiment data.
|
86
|
+
def context(context = nil)
|
87
|
+
@_scientist_context ||= {}
|
88
|
+
@_scientist_context.merge!(context) if !context.nil?
|
89
|
+
@_scientist_context
|
90
|
+
end
|
91
|
+
|
92
|
+
# Configure this experiment to ignore an observation with the given block.
|
93
|
+
#
|
94
|
+
# The block takes two arguments, the control observation and the candidate
|
95
|
+
# observation which didn't match the control. If the block returns true, the
|
96
|
+
# mismatch is disregarded.
|
97
|
+
#
|
98
|
+
# This can be called more than once with different blocks to use.
|
99
|
+
def ignore(&block)
|
100
|
+
@_scientist_ignores ||= []
|
101
|
+
@_scientist_ignores << block
|
102
|
+
end
|
103
|
+
|
104
|
+
# Internal: ignore a mismatched observation?
|
105
|
+
#
|
106
|
+
# Iterates through the configured ignore blocks and calls each of them with
|
107
|
+
# the given control and mismatched candidate observations.
|
108
|
+
#
|
109
|
+
# Returns true or false.
|
110
|
+
def ignore_mismatched_observation?(control, candidate)
|
111
|
+
return false unless @_scientist_ignores
|
112
|
+
@_scientist_ignores.any? do |ignore|
|
113
|
+
begin
|
114
|
+
ignore.call control.value, candidate.value
|
115
|
+
rescue StandardError => ex
|
116
|
+
raised :ignore, ex
|
117
|
+
false
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# The String name of this experiment. Default is "experiment". See
|
123
|
+
# Scientist::Default for an example of how to override this default.
|
124
|
+
def name
|
125
|
+
"experiment"
|
126
|
+
end
|
127
|
+
|
128
|
+
# Internal: compare two observations, using the configured compare block if present.
|
129
|
+
def observations_are_equivalent?(a, b)
|
130
|
+
if @_scientist_comparator
|
131
|
+
a.equivalent_to?(b, &@_scientist_comparator)
|
132
|
+
else
|
133
|
+
a.equivalent_to? b
|
134
|
+
end
|
135
|
+
rescue StandardError => ex
|
136
|
+
raised :compare, ex
|
137
|
+
false
|
138
|
+
end
|
139
|
+
|
140
|
+
# Called when an exception is raised while running an internal operation,
|
141
|
+
# like :publish. Override this method to track these exceptions. The
|
142
|
+
# default implementation re-raises the exception.
|
143
|
+
def raised(operation, error)
|
144
|
+
raise error
|
145
|
+
end
|
146
|
+
|
147
|
+
# Internal: Run all the behaviors for this experiment, observing each and
|
148
|
+
# publishing the results. Return the result of the named behavior, default
|
149
|
+
# "control".
|
150
|
+
def run(name = nil)
|
151
|
+
behaviors.freeze
|
152
|
+
context.freeze
|
153
|
+
|
154
|
+
name = (name || "control").to_s
|
155
|
+
block = behaviors[name]
|
156
|
+
|
157
|
+
if block.nil?
|
158
|
+
raise Scientist::BehaviorMissing.new(self, name)
|
159
|
+
end
|
160
|
+
|
161
|
+
return block.call unless should_experiment_run?
|
162
|
+
|
163
|
+
observations = []
|
164
|
+
|
165
|
+
behaviors.keys.shuffle.each do |key|
|
166
|
+
block = behaviors[key]
|
167
|
+
observations << Scientist::Observation.new(key, self, &block)
|
168
|
+
end
|
169
|
+
|
170
|
+
control = observations.detect { |o| o.name == name }
|
171
|
+
|
172
|
+
result = Scientist::Result.new self,
|
173
|
+
observations: observations,
|
174
|
+
control: control
|
175
|
+
|
176
|
+
begin
|
177
|
+
publish(result)
|
178
|
+
rescue StandardError => ex
|
179
|
+
raised :publish, ex
|
180
|
+
end
|
181
|
+
|
182
|
+
if control.raised?
|
183
|
+
raise control.exception
|
184
|
+
end
|
185
|
+
|
186
|
+
if self.class.raise_on_mismatches? && result.mismatched?
|
187
|
+
raise MismatchError.new(name, result)
|
188
|
+
end
|
189
|
+
|
190
|
+
control.value
|
191
|
+
end
|
192
|
+
|
193
|
+
# Define a block that determines whether or not the experiment should run.
|
194
|
+
def run_if(&block)
|
195
|
+
@_scientist_run_if_block = block
|
196
|
+
end
|
197
|
+
|
198
|
+
# Internal: does a run_if block allow the experiment to run?
|
199
|
+
#
|
200
|
+
# Rescues and reports exceptions in a run_if block if they occur.
|
201
|
+
def run_if_block_allows?
|
202
|
+
(@_scientist_run_if_block ? @_scientist_run_if_block.call : true)
|
203
|
+
rescue StandardError => ex
|
204
|
+
raised :run_if, ex
|
205
|
+
return false
|
206
|
+
end
|
207
|
+
|
208
|
+
# Internal: determine whether or not an experiment should run.
|
209
|
+
#
|
210
|
+
# Rescues and reports exceptions in the enabled method if they occur.
|
211
|
+
def should_experiment_run?
|
212
|
+
behaviors.size > 1 && enabled? && run_if_block_allows?
|
213
|
+
rescue StandardError => ex
|
214
|
+
raised :enabled, ex
|
215
|
+
return false
|
216
|
+
end
|
217
|
+
|
218
|
+
# Register a named behavior for this experiment, default "candidate".
|
219
|
+
def try(name = nil, &block)
|
220
|
+
name = (name || "candidate").to_s
|
221
|
+
|
222
|
+
if behaviors.include?(name)
|
223
|
+
raise Scientist::BehaviorNotUnique.new(self, name)
|
224
|
+
end
|
225
|
+
|
226
|
+
behaviors[name] = block
|
227
|
+
end
|
228
|
+
|
229
|
+
# Register the control behavior for this experiment.
|
230
|
+
def use(&block)
|
231
|
+
try "control", &block
|
232
|
+
end
|
233
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# What happened when this named behavior was executed? Immutable.
|
2
|
+
class Scientist::Observation
|
3
|
+
|
4
|
+
# The experiment this observation is for
|
5
|
+
attr_reader :experiment
|
6
|
+
|
7
|
+
# The instant observation began.
|
8
|
+
attr_reader :now
|
9
|
+
|
10
|
+
# The String name of the behavior.
|
11
|
+
attr_reader :name
|
12
|
+
|
13
|
+
# The value returned, if any.
|
14
|
+
attr_reader :value
|
15
|
+
|
16
|
+
# The raised exception, if any.
|
17
|
+
attr_reader :exception
|
18
|
+
|
19
|
+
# The Float seconds elapsed.
|
20
|
+
attr_reader :duration
|
21
|
+
|
22
|
+
def initialize(name, experiment, &block)
|
23
|
+
@name = name
|
24
|
+
@experiment = experiment
|
25
|
+
@now = Time.now
|
26
|
+
|
27
|
+
begin
|
28
|
+
@value = block.call
|
29
|
+
rescue Object => e
|
30
|
+
@exception = e
|
31
|
+
end
|
32
|
+
|
33
|
+
@duration = (Time.now - @now).to_f
|
34
|
+
|
35
|
+
freeze
|
36
|
+
end
|
37
|
+
|
38
|
+
# Return a cleaned value suitable for publishing. Uses the experiment's
|
39
|
+
# defined cleaner block to clean the observed value.
|
40
|
+
def cleaned_value
|
41
|
+
if value
|
42
|
+
experiment.clean_value value
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# Is this observation equivalent to another?
|
47
|
+
#
|
48
|
+
# other - the other Observation in question
|
49
|
+
# comparator - an optional comparison block. This observation's value and the
|
50
|
+
# other observation's value are yielded to this to determine
|
51
|
+
# their equivalency. Block should return true/false.
|
52
|
+
#
|
53
|
+
# Returns true if:
|
54
|
+
#
|
55
|
+
# * The values of the observation are equal (using `==`)
|
56
|
+
# * The values of the observations are equal according to a comparison
|
57
|
+
# block, if given
|
58
|
+
# * Both observations raised an exception with the same class and message.
|
59
|
+
#
|
60
|
+
# Returns false otherwise.
|
61
|
+
def equivalent_to?(other, &comparator)
|
62
|
+
return false unless other.is_a?(Scientist::Observation)
|
63
|
+
|
64
|
+
values_are_equal = false
|
65
|
+
both_raised = other.raised? && raised?
|
66
|
+
neither_raised = !other.raised? && !raised?
|
67
|
+
|
68
|
+
if neither_raised
|
69
|
+
if block_given?
|
70
|
+
values_are_equal = yield value, other.value
|
71
|
+
else
|
72
|
+
values_are_equal = value == other.value
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
exceptions_are_equivalent = # backtraces will differ, natch
|
77
|
+
both_raised &&
|
78
|
+
other.exception.class == exception.class &&
|
79
|
+
other.exception.message == exception.message
|
80
|
+
|
81
|
+
(neither_raised && values_are_equal) ||
|
82
|
+
(both_raised && exceptions_are_equivalent)
|
83
|
+
end
|
84
|
+
|
85
|
+
def hash
|
86
|
+
[value, exception, self.class].compact.map(&:hash).inject(:^)
|
87
|
+
end
|
88
|
+
|
89
|
+
def raised?
|
90
|
+
!exception.nil?
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# The immutable result of running an experiment.
|
2
|
+
class Scientist::Result
|
3
|
+
|
4
|
+
# An Array of candidate Observations.
|
5
|
+
attr_reader :candidates
|
6
|
+
|
7
|
+
# The control Observation to which the rest are compared.
|
8
|
+
attr_reader :control
|
9
|
+
|
10
|
+
# An Experiment.
|
11
|
+
attr_reader :experiment
|
12
|
+
|
13
|
+
# An Array of observations which didn't match the control, but were ignored.
|
14
|
+
attr_reader :ignored
|
15
|
+
|
16
|
+
# An Array of observations which didn't match the control.
|
17
|
+
attr_reader :mismatched
|
18
|
+
|
19
|
+
# An Array of Observations in execution order.
|
20
|
+
attr_reader :observations
|
21
|
+
|
22
|
+
# Internal: Create a new result.
|
23
|
+
#
|
24
|
+
# experiment - the Experiment this result is for
|
25
|
+
# observations: - an Array of Observations, in execution order
|
26
|
+
# control: - the control Observation
|
27
|
+
#
|
28
|
+
def initialize(experiment, observations:, control:)
|
29
|
+
@experiment = experiment
|
30
|
+
@observations = observations
|
31
|
+
@control = control
|
32
|
+
@candidates = observations - [control]
|
33
|
+
evaluate_candidates
|
34
|
+
|
35
|
+
freeze
|
36
|
+
end
|
37
|
+
|
38
|
+
# Public: the experiment's context
|
39
|
+
def context
|
40
|
+
experiment.context
|
41
|
+
end
|
42
|
+
|
43
|
+
# Public: the name of the experiment
|
44
|
+
def experiment_name
|
45
|
+
experiment.name
|
46
|
+
end
|
47
|
+
|
48
|
+
# Public: was the result a match between all behaviors?
|
49
|
+
def matched?
|
50
|
+
mismatched.empty?
|
51
|
+
end
|
52
|
+
|
53
|
+
# Public: were there mismatches in the behaviors?
|
54
|
+
def mismatched?
|
55
|
+
mismatched.any?
|
56
|
+
end
|
57
|
+
|
58
|
+
# Public: were there any ignored mismatches?
|
59
|
+
def ignored?
|
60
|
+
ignored.any?
|
61
|
+
end
|
62
|
+
|
63
|
+
# Internal: evaluate the candidates to find mismatched and ignored results
|
64
|
+
#
|
65
|
+
# Sets @ignored and @mismatched with the ignored and mismatched candidates.
|
66
|
+
def evaluate_candidates
|
67
|
+
mismatched = candidates.reject do |candidate|
|
68
|
+
experiment.observations_are_equivalent?(control, candidate)
|
69
|
+
end
|
70
|
+
|
71
|
+
@ignored = mismatched.select do |candidate|
|
72
|
+
experiment.ignore_mismatched_observation? control, candidate
|
73
|
+
end
|
74
|
+
|
75
|
+
@mismatched = mismatched - @ignored
|
76
|
+
end
|
77
|
+
end
|
data/lib/scientist/version.rb
CHANGED
data/lib/scientist.rb
CHANGED
@@ -1,4 +1,41 @@
|
|
1
|
-
|
2
|
-
|
1
|
+
# Include this module into any class which requires science experiments in its
|
2
|
+
# methods. Provides the `science` and `default_scientist_context` methods for
|
3
|
+
# defining and running experiments.
|
4
|
+
#
|
5
|
+
# If you need to run science on class methods, extend this module instead.
|
3
6
|
module Scientist
|
7
|
+
# Define and run a science experiment.
|
8
|
+
#
|
9
|
+
# name - a String name for this experiment.
|
10
|
+
# run: - optional argument for which named test to run instead of "control".
|
11
|
+
#
|
12
|
+
# Yields an object which implements the Scientist::Experiment interface.
|
13
|
+
# See `Scientist::Experiment.new` for how this is defined.
|
14
|
+
#
|
15
|
+
# Returns the calculated value of the control experiment, or raises if an
|
16
|
+
# exception was raised.
|
17
|
+
def science(name, run: nil)
|
18
|
+
experiment = Experiment.new(name)
|
19
|
+
experiment.context(default_scientist_context)
|
20
|
+
|
21
|
+
yield experiment
|
22
|
+
|
23
|
+
experiment.run(run)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Public: the default context data for an experiment created and run via the
|
27
|
+
# `science` helper method. Override this in any class that includes Scientist
|
28
|
+
# to define your own behavior.
|
29
|
+
#
|
30
|
+
# Returns a Hash.
|
31
|
+
def default_scientist_context
|
32
|
+
{}
|
33
|
+
end
|
4
34
|
end
|
35
|
+
|
36
|
+
require "scientist/default"
|
37
|
+
require "scientist/errors"
|
38
|
+
require "scientist/experiment"
|
39
|
+
require "scientist/observation"
|
40
|
+
require "scientist/result"
|
41
|
+
require "scientist/version"
|
data/scientist.gemspec
CHANGED
@@ -1,7 +1,8 @@
|
|
1
|
-
|
1
|
+
$: << "lib" and require "scientist/version"
|
2
2
|
|
3
3
|
Gem::Specification.new do |gem|
|
4
4
|
gem.name = "scientist"
|
5
|
+
gem.description = "A Ruby library for carefully refactoring critical paths"
|
5
6
|
gem.version = Scientist::VERSION
|
6
7
|
gem.authors = ["John Barnette", "Rick Bradley"]
|
7
8
|
gem.email = ["jbarnette@github.com", "rick@github.com"]
|
@@ -9,11 +10,12 @@ Gem::Specification.new do |gem|
|
|
9
10
|
gem.homepage = "https://github.com/github/scientist"
|
10
11
|
gem.license = "MIT"
|
11
12
|
|
13
|
+
gem.required_ruby_version = ">= 2.1.0"
|
14
|
+
|
12
15
|
gem.files = `git ls-files`.split($/)
|
13
16
|
gem.executables = []
|
14
17
|
gem.test_files = gem.files.grep(/^test/)
|
15
18
|
gem.require_paths = ["lib"]
|
16
19
|
|
17
|
-
gem.add_development_dependency "minitest", "~> 5.2
|
18
|
-
gem.add_development_dependency "mocha", "~> 1.0.0"
|
20
|
+
gem.add_development_dependency "minitest", "~> 5.2"
|
19
21
|
end
|
data/script/test
CHANGED
@@ -4,5 +4,8 @@
|
|
4
4
|
set -e
|
5
5
|
|
6
6
|
cd $(dirname "$0")/..
|
7
|
-
script/bootstrap && ruby -I lib
|
7
|
+
script/bootstrap && ruby -I lib \
|
8
|
+
-e 'require "bundler/setup"' \
|
9
|
+
-e 'require "minitest/autorun"' \
|
10
|
+
-e 'require "scientist"' \
|
8
11
|
-e '(ARGV.empty? ? Dir["test/**/*_test.rb"] : ARGV).each { |f| load f }' -- "$@"
|
@@ -0,0 +1,23 @@
|
|
1
|
+
describe Scientist::Default do
|
2
|
+
before do
|
3
|
+
@ex = Scientist::Default.new "default"
|
4
|
+
end
|
5
|
+
|
6
|
+
it "is always enabled" do
|
7
|
+
assert @ex.enabled?
|
8
|
+
end
|
9
|
+
|
10
|
+
it "noops publish" do
|
11
|
+
assert_nil @ex.publish("data")
|
12
|
+
end
|
13
|
+
|
14
|
+
it "is an experiment" do
|
15
|
+
assert Scientist::Default < Scientist::Experiment
|
16
|
+
end
|
17
|
+
|
18
|
+
it "reraises when an internal action raises" do
|
19
|
+
assert_raises RuntimeError do
|
20
|
+
@ex.raised :publish, RuntimeError.new("kaboom")
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|