scientist 0.0.0 → 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -1,3 +1,3 @@
1
1
  module Scientist
2
- VERSION = "0.0.0"
2
+ VERSION = "0.0.1"
3
3
  end
data/lib/scientist.rb CHANGED
@@ -1,4 +1,41 @@
1
- require "scientist/version"
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
- load "lib/scientist/version.rb"
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.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 -e 'require "bundler/setup"' \
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