lab_tech 0.1.0 → 0.1.5

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2f5e6f3f09101904d4cf6fbc209768dba3f58bdb3ca9bbe157473e7d939fe124
4
- data.tar.gz: 88dceb210ec735257a5f2cf329fe132085062b39dd2ad207dae2d0ff16392993
3
+ metadata.gz: 9ae9dc5a34d835d29b76b516dca2dcd6db786c44be5207f38d007104b1dda180
4
+ data.tar.gz: e47a125456c58867e841f88301a998e05c5750c7df0c74e6fb1c92907126949a
5
5
  SHA512:
6
- metadata.gz: b52697112c48077ac53e85af052b0e20e5425f448fba77a8139344afad7bb39c4bf497dcf5987d794d5826e5a6dfd920975a0d509d44fb16779d92342d21ffbd
7
- data.tar.gz: d6178294c5066e7117435237b7ba2a93a1bbf15bba7152ead2c6f4393a808c0af1d271478126e50a5948ee133c855fa28ca2589da140a305cc5698271ce0fd81
6
+ metadata.gz: 3941c70268e050eff64b8673401c66e7457a931c6f6bc906cac0218756068fdbaf8f3e1d1f50460164032f63843aefee8b9483d655514a8b47b6121936bc3592
7
+ data.tar.gz: ce7f941d62618a9de15face19a82d9fdd9bbea27a742602be37cf95efe9629e55a588fe0f974bfb15817d7f24bceb45036ebaee070d621fa9a6857f22aaba2b1
data/README.md CHANGED
@@ -17,6 +17,33 @@ for accuracy and performance. (Please feel free to send those back to us in a
17
17
  pull request; we simply haven't needed them for ourselves, so they don't
18
18
  exist yet.)
19
19
 
20
+ ## Why Scientist?
21
+
22
+ Scientist is a great tool for trying out changes where:
23
+ - comprehensive test coverage is impractical
24
+ - your test suite doesn't give you sufficient confidence in your changes
25
+ - you want detailed performance data on your proposed alternative(s)
26
+
27
+ ## Why LabTech?
28
+
29
+ Scientist is amazing at **generating** data, but it assumes you'll develop your
30
+ own tools for **recording** and **analyzing** it. Scientist's README examples
31
+ show interactions with StatsD and Redis, but if you're working in a Rails app,
32
+ odds are *pretty darn good* that:
33
+
34
+ 1. you already have access to a RDBMS and ActiveRecord, and
35
+ 2. your throughput isn't so huge that some extra database writes will bring
36
+ said RDBMS to its knees.
37
+
38
+ If both of those assumptions are true for your application, LabTech might be a
39
+ good fit for you -- it records experimental results to the database so they're
40
+ easy to query later using ActiveRecord.
41
+
42
+ (If you're legitimately worried about the I/O load on your RDBMS, you can
43
+ always ramp up your LabTech experiments a percentage point or two at a time,
44
+ keeping an eye on your performance monitoring tools and scaling back as
45
+ needed.)
46
+
20
47
  ## Usage
21
48
 
22
49
  Once you've installed the gem and run its migrations (as described in
@@ -257,6 +284,8 @@ mismatches by running:
257
284
  LabTech.compare_mismatches "spiffy-search", limit: 3
258
285
  ```
259
286
 
287
+ (To view all mismatches, just leave off the `limit: 3`.)
288
+
260
289
  You have the ability to customize the output of this by passing a block that
261
290
  takes a "control" parameter followed by a "candidate" parameter; the return
262
291
  value of that block will be printed to the console. How you do this will
@@ -264,6 +293,34 @@ largely depend on the kind of data you're collecting to validate your
264
293
  experiments. There are several examples in the `lib/lab_tech.rb` file; I
265
294
  encourage you to check them out.
266
295
 
296
+ If you have errors to inspect as well, you can view these with:
297
+
298
+ ```ruby
299
+ LabTech.summarize_errors "spiffy-search"
300
+ ```
301
+
302
+ Note that the `summarize_errors` method also takes an optional `:limit` keyword
303
+ argument.
304
+
305
+ ### Storing Diffs
306
+
307
+ If you're working with complex data, you might not want to recompute the diffs
308
+ from console. As such, LabTech adds a `.diff` method to the experiment. If
309
+ you call this with a block, that block will be passed the control and candidate
310
+ for each candidate, and its result will be stored on the `LabTech::Observation`
311
+ record. (See the "diff-generating behavior" spec in
312
+ `spec/models/lab_tech/experiment_spec.rb` for examples.)
313
+
314
+ ### A Note About Experimental Design
315
+
316
+ Scientist supports experiments with more than one candidate at a time, and
317
+ therefore so does LabTech -- it will record as many candidates as you throw at
318
+ it. However, if you have multiple candidates, we don't have a good way to
319
+ generate performance charts to compare all of the alternatives, so LabTech just
320
+ doesn't bother printing them. **If you try this, you're on your own.** (But
321
+ do let us know how it goes, and feel free to submit a PR if you find a good
322
+ solution!)
323
+
267
324
  ## Installation
268
325
 
269
326
  **NOTE: As this gem is a Rails engine, we assume you have a Rails application to
@@ -311,11 +368,18 @@ Once that's done, you should be good to go! See the "Usage" section, above.
311
368
 
312
369
  ## Contributing
313
370
 
314
- This gem was extracted just before its primary author left Real Geeks, so it's
315
- not quite clear who's going to take responsibility for the gem. It's probably
316
- a good idea to open a GitHub issue to start a conversation before undertaking
317
- any great amount of work -- though, of course, you're perfectly welcome to fork
318
- the gem and use your modified version at any time.
371
+ Bug reports and pull requests are welcome on GitHub at
372
+ https://github.com/RealGeeks/lab_tech.
373
+
374
+ It's probably a good idea to open a GitHub issue to start a conversation before
375
+ undertaking any significant amount of work -- though, as always with F/OSS
376
+ code, you're perfectly welcome to fork the gem and use your modified version at
377
+ any time.
378
+
379
+ This project is intended to be a safe, welcoming space for collaboration.
380
+ While we have not yet formally adopted a code of conduct, it's probably a Very
381
+ Good Idea to act in accordance with the <a
382
+ href="https://www.contributor-covenant.org/">Contributor Covenant</a>.
319
383
 
320
384
  ## License
321
385
 
@@ -60,29 +60,19 @@ module LabTech
60
60
  @_scientist_comparator
61
61
  end
62
62
 
63
- # TODO: DRY up the io.puts structure between this and summarize_errors
64
- def compare_mismatches(limit: nil, io: $stdout, &block)
63
+ def compare_mismatches(limit: nil, width: 100, io: $stdout, &block)
65
64
  mismatches = results.mismatched.includes(:observations)
66
65
  return if mismatches.empty?
67
66
  mismatches = mismatches.limit(limit) if limit
68
67
 
69
- io.puts
70
- io.puts "=" * 100
71
- io.puts "Comparing results for #{name}:"
72
- io.puts
73
-
74
- mismatches.each do |result|
75
- io.puts
76
- io.puts "-" * 100
68
+ display_results mismatches, label: "Comparing results for #{name}:", io: io do |result|
77
69
  io.puts "Result ##{result.id}"
78
70
  result.compare_observations( io: io, &block )
79
- io.puts "-" * 100
80
71
  end
72
+ end
81
73
 
82
- io.puts
83
- io.puts "=" * 100
84
- io.puts
85
- nil
74
+ def diff(&block)
75
+ @diff_with = block
86
76
  end
87
77
 
88
78
  def disable
@@ -106,7 +96,7 @@ module LabTech
106
96
 
107
97
  def publish(scientist_result)
108
98
  return if Rails.env.test? && !LabTech.publish_results_in_test_mode?
109
- LabTech::Result.record_a_science( self, scientist_result )
99
+ LabTech::Result.record_a_science( self, scientist_result, diff_with: @diff_with )
110
100
  end
111
101
 
112
102
  # I don't encourage the willy-nilly destruction of experimental results...
@@ -125,7 +115,7 @@ module LabTech
125
115
  n = delete_and_count.call( LabTech::Observation.where(result_id: self.result_ids) )
126
116
  m = delete_and_count.call( self.results )
127
117
 
128
- update_attributes(
118
+ update(
129
119
  equivalent_count: 0,
130
120
  timed_out_count: 0,
131
121
  other_error_count: 0,
@@ -140,31 +130,17 @@ module LabTech
140
130
  super
141
131
  end
142
132
 
143
- # TODO: DRY up the io.puts structure between this and compare_mismatches
144
- def summarize_errors(limit: nil, io: $stdout)
133
+ def summarize_errors(limit: nil, width: 100, io: $stdout)
145
134
  errors = results.other_error
146
135
  return if errors.empty?
147
136
  errors = errors.limit(limit) if limit
148
137
 
149
- io.puts
150
- io.puts "=" * 100
151
- io.puts "Comparing results for #{name}:"
152
- io.puts
153
-
154
- errors.each do |result|
155
- io.puts
156
- io.puts "-" * 100
138
+ display_results errors, label: "Summarizing errors for #{name}:", io: io do |result|
157
139
  io.puts "Result ##{result.id}"
158
140
  result.candidates.each do |observation|
159
- puts " * " + observation.exception_class + ": " + observation.exception_message
141
+ io.puts " * " + observation.exception_class + ": " + observation.exception_message
160
142
  end
161
- io.puts "-" * 100
162
143
  end
163
-
164
- io.puts
165
- io.puts "=" * 100
166
- io.puts
167
- nil
168
144
  end
169
145
 
170
146
  def summarize_results
@@ -186,5 +162,27 @@ module LabTech
186
162
  return if cleaner.present?
187
163
  clean { |value| LabTech::DefaultCleaner.call(value) }
188
164
  end
165
+
166
+ def display_results(results, label: nil, width: 100, io: $stdout)
167
+ return if results.empty?
168
+
169
+ io.puts
170
+ io.puts "=" * width
171
+ io.puts label if label
172
+ io.puts
173
+
174
+ results.each do |result|
175
+ io.puts
176
+ io.puts "-" * width
177
+ yield result
178
+ io.puts "-" * width
179
+ end
180
+
181
+ io.puts
182
+ io.puts "=" * width
183
+ io.puts
184
+
185
+ return nil
186
+ end
189
187
  end
190
188
  end
@@ -4,6 +4,9 @@ module LabTech
4
4
 
5
5
  belongs_to :result, class_name: "LabTech::Result", foreign_key: :result_id, optional: true
6
6
 
7
+ scope :timed_out, -> { where(exception_class: 'Timeout::Error') }
8
+ scope :other_error, -> { where.not(exception_class: 'Timeout::Error') }
9
+
7
10
  serialize :value
8
11
 
9
12
  def raised_error?
@@ -4,8 +4,8 @@ module LabTech
4
4
 
5
5
  belongs_to :experiment, class_name: "LabTech::Experiment"
6
6
  has_many :observations, class_name: "LabTech::Observation", dependent: :destroy
7
- has_one :control, ->() { where("name = 'control'") }, class_name: "LabTech::Observation"
8
- has_many :candidates, ->() { where("name != 'control'") }, class_name: "LabTech::Observation"
7
+ has_one :control, ->() { where(name: 'control') }, class_name: "LabTech::Observation"
8
+ has_many :candidates, ->() { where.not(name: 'control') }, class_name: "LabTech::Observation"
9
9
  serialize :context
10
10
 
11
11
  # NOTE: I don't think this accounts for the possibility that both the
@@ -14,23 +14,17 @@ module LabTech
14
14
  scope :correct, -> { where( equivalent: true, raised_error: false ) }
15
15
  scope :mismatched, -> { where( equivalent: false, raised_error: false ) }
16
16
  scope :errored, -> { where( equivalent: false, raised_error: true ) }
17
- is_timeout = ->(is_or_is_not) {
18
- col = LabTech::Observation.table_name + ".exception_class"
19
- operator = is_or_is_not ? "=" : "!="
20
- value = '"Timeout::Error"'
21
- [ col, operator, value ].join(" ")
22
- }
23
- scope :timed_out, -> { errored.joins(:candidates).where( is_timeout.(true) ) }
24
- scope :other_error, -> { errored.joins(:candidates).where( is_timeout.(false) ) }
17
+ scope :timed_out, -> { errored.joins(:candidates).merge(Observation.timed_out) }
18
+ scope :other_error, -> { errored.joins(:candidates).merge(Observation.other_error) }
25
19
 
26
20
  after_create :increment_experiment_counters
27
21
 
28
22
 
29
23
  ##### CLASS METHODS #####
30
24
 
31
- def self.record_a_science( experiment, scientist_result )
25
+ def self.record_a_science( experiment, scientist_result, **kwargs )
32
26
  self.create!(experiment: experiment) do |result|
33
- result.record_a_science scientist_result
27
+ result.record_a_science scientist_result, **kwargs
34
28
  end
35
29
  end
36
30
 
@@ -56,7 +50,7 @@ module LabTech
56
50
  return nil
57
51
  end
58
52
 
59
- def record_a_science(scientist_result)
53
+ def record_a_science(scientist_result, diff_with: nil)
60
54
  unless scientist_result.kind_of?( Scientist::Result )
61
55
  raise ArgumentError, "expected a Scientist::Result but got #{scientist_result.class}"
62
56
  end
@@ -65,7 +59,8 @@ module LabTech
65
59
 
66
60
  record_observation scientist_result.control
67
61
  scientist_result.candidates.each do |candidate|
68
- record_observation candidate
62
+ diff = diff_with&.call(scientist_result.control, candidate)
63
+ record_observation candidate, diff: diff
69
64
  end
70
65
 
71
66
  record_simple_stats scientist_result
@@ -99,8 +94,9 @@ module LabTech
99
94
  end
100
95
  end
101
96
 
102
- def record_observation(scientist_observation)
97
+ def record_observation(scientist_observation, attrs = {})
103
98
  self.observations.build do |observation|
99
+ observation.assign_attributes attrs if attrs.present?
104
100
  observation.record_a_science scientist_observation
105
101
  end
106
102
  end
@@ -2,8 +2,6 @@ module LabTech
2
2
  class Summary
3
3
  TAB = " " * 4
4
4
  LINE = "-" * 80
5
- VAL = "█"
6
- DOT = "·"
7
5
 
8
6
  def initialize(experiment)
9
7
  @experiment = experiment
@@ -46,10 +44,9 @@ module LabTech
46
44
  def add_speedup_chart_to(s)
47
45
  s.puts
48
46
  s.puts "Speedups (by percentiles):"
49
- speedup_magnitude = @speedup_factors.minmax.map(&:to_i).map(&:abs).max.ceil
50
- speedup_magnitude = 25 if speedup_magnitude.zero?
51
47
  (0..100).step(5) do |n|
52
- s.puts TAB + speedup_summary_line(n, speedup_magnitude)
48
+ line = SpeedupLine.new(n, @speedup_factors)
49
+ s.puts TAB + line.to_s
53
50
  end
54
51
  end
55
52
 
@@ -100,83 +97,12 @@ module LabTech
100
97
  end
101
98
  end
102
99
 
103
- def highlight_bar(bar)
104
- left, right = bar.split(VAL)
105
-
106
- left = left .gsub(" ", " #{DOT}")
107
- right = right.reverse.gsub(" ", " #{DOT}").reverse
108
-
109
- left + VAL + right
110
- end
111
-
112
- def humanize(n)
113
- width = number_helper.number_with_delimiter( @counts[:results] ).length
114
- "%#{width}s" % number_helper.number_with_delimiter( n )
115
- end
116
-
117
- def pad_left(s, width)
118
- n = [ ( width - s.length ), 0 ].max
119
- [ " " * n , s ].join
120
- end
121
-
122
- def normalized_bar(x, magnitude, bar_scale: 25, highlight: false)
123
- neg, pos = " " * bar_scale, " " * bar_scale
124
- normalized = ( bar_scale * ( x.abs / magnitude ) ).floor
125
-
126
- # Select an index that's as close to `normalized` as possible without generating IndexErrors
127
- # (TODO: actually understand the math involved so I don't have to chop the ends off like an infidel)
128
- index = [ 0, normalized ].max
129
- index = [ index, bar_scale - 1 ].min
130
-
131
- case
132
- when x == 0 ; mid = VAL
133
- when x < 0 ; mid = DOT ; neg[ index ] = VAL ; neg = neg.reverse
134
- when x > 0 ; mid = DOT ; pos[ index ] = VAL
135
- end
136
-
137
- bar = "[%s%s%s]" % [ neg, mid, pos ]
138
- bar = highlight_bar(bar) if highlight
139
- bar
140
- end
141
-
142
- def number_helper
143
- @_number_helper ||= Object.new.tap {|o| o.send :extend, ActionView::Helpers::NumberHelper }
144
- end
145
-
146
- def rate(n)
147
- "%2.2f%%" % ( 100.0 * n / @counts[:results] )
148
- end
149
-
150
- def speedup_summary_line(n, speedup_magnitude)
151
- highlight = n == 50
152
- label = "%3d%%" % n
153
-
154
- speedup_factor = LabTech::Percentile.call(n, @speedup_factors)
155
- rel_speedup = "%+.1fx" % speedup_factor
156
- bar = normalized_bar( speedup_factor, speedup_magnitude, highlight: highlight)
157
-
158
- speedup_cue = pad_left( rel_speedup, speedup_width )
159
- speedup_cue += " faster" if speedup_factor > 0
160
-
161
- "#{label} #{bar} #{speedup_cue}"
162
- end
163
-
164
- def speedup_width
165
- @_speedup_width ||= [
166
- 1, # sign
167
- 4, # digits
168
- 1, # decimal point
169
- 1, # digit after decimal point
170
- ].sum
171
- end
172
-
173
100
  def summarize_count(s, count_name, label = nil)
174
- count = @counts[count_name]
175
- return if count.zero?
176
-
101
+ n = @counts[count_name]
177
102
  total = @counts[:results]
178
- label ||= count_name.to_s
179
- s.puts "%s of %s (%s) %s" % [ humanize( count ), humanize( total ), rate( count ), label ]
103
+ count = Count.new(count_name, n, total, label)
104
+ return if count.zero?
105
+ s.puts count.to_s
180
106
  end
181
107
 
182
108
  end
@@ -0,0 +1,44 @@
1
+ module LabTech
2
+ class Summary
3
+
4
+ class Count
5
+ attr_reader :name, :n, :total, :label
6
+
7
+ def initialize(name, n, total, label = nil)
8
+ @name = name
9
+ @n = n
10
+ @total = total
11
+ @label = label || name.to_s
12
+ end
13
+
14
+ def zero?
15
+ n.zero?
16
+ end
17
+
18
+ def to_s
19
+ "%s of %s (%s) %s" % [
20
+ humanize( n ),
21
+ humanize( total ),
22
+ rate( n ),
23
+ label
24
+ ]
25
+ end
26
+
27
+ private
28
+
29
+ def humanize(n)
30
+ width = number_helper.number_with_delimiter( n ).length
31
+ "%#{width}s" % number_helper.number_with_delimiter( n )
32
+ end
33
+
34
+ def number_helper
35
+ @_number_helper ||= Object.new.tap {|o| o.send :extend, ActionView::Helpers::NumberHelper }
36
+ end
37
+
38
+ def rate(n)
39
+ "%2.2f%%" % ( 100.0 * n / total )
40
+ end
41
+ end
42
+
43
+ end
44
+ end