lab_tech 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +323 -0
  4. data/Rakefile +30 -0
  5. data/app/models/lab_tech/application_record.rb +5 -0
  6. data/app/models/lab_tech/default_cleaner.rb +87 -0
  7. data/app/models/lab_tech/experiment.rb +190 -0
  8. data/app/models/lab_tech/observation.rb +40 -0
  9. data/app/models/lab_tech/percentile.rb +41 -0
  10. data/app/models/lab_tech/result.rb +130 -0
  11. data/app/models/lab_tech/speedup.rb +65 -0
  12. data/app/models/lab_tech/summary.rb +183 -0
  13. data/config/routes.rb +2 -0
  14. data/db/migrate/20190815192130_create_experiment_tables.rb +50 -0
  15. data/lib/lab_tech.rb +176 -0
  16. data/lib/lab_tech/engine.rb +6 -0
  17. data/lib/lab_tech/version.rb +3 -0
  18. data/lib/tasks/lab_tech_tasks.rake +4 -0
  19. data/spec/dummy/Rakefile +6 -0
  20. data/spec/dummy/app/assets/config/manifest.js +1 -0
  21. data/spec/dummy/app/assets/javascripts/application.js +14 -0
  22. data/spec/dummy/app/assets/stylesheets/application.css +15 -0
  23. data/spec/dummy/app/controllers/application_controller.rb +2 -0
  24. data/spec/dummy/app/jobs/application_job.rb +2 -0
  25. data/spec/dummy/app/models/application_record.rb +3 -0
  26. data/spec/dummy/bin/bundle +3 -0
  27. data/spec/dummy/bin/rails +4 -0
  28. data/spec/dummy/bin/rake +4 -0
  29. data/spec/dummy/bin/setup +33 -0
  30. data/spec/dummy/bin/update +28 -0
  31. data/spec/dummy/config.ru +5 -0
  32. data/spec/dummy/config/application.rb +35 -0
  33. data/spec/dummy/config/boot.rb +5 -0
  34. data/spec/dummy/config/database.yml +25 -0
  35. data/spec/dummy/config/environment.rb +5 -0
  36. data/spec/dummy/config/environments/development.rb +46 -0
  37. data/spec/dummy/config/environments/production.rb +71 -0
  38. data/spec/dummy/config/environments/test.rb +36 -0
  39. data/spec/dummy/config/initializers/application_controller_renderer.rb +8 -0
  40. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  41. data/spec/dummy/config/initializers/cors.rb +16 -0
  42. data/spec/dummy/config/initializers/filter_parameter_logging.rb +4 -0
  43. data/spec/dummy/config/initializers/inflections.rb +16 -0
  44. data/spec/dummy/config/initializers/mime_types.rb +4 -0
  45. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  46. data/spec/dummy/config/locales/en.yml +33 -0
  47. data/spec/dummy/config/puma.rb +34 -0
  48. data/spec/dummy/config/routes.rb +3 -0
  49. data/spec/dummy/config/spring.rb +6 -0
  50. data/spec/dummy/db/schema.rb +52 -0
  51. data/spec/dummy/db/test.sqlite3 +0 -0
  52. data/spec/dummy/log/development.log +0 -0
  53. data/spec/dummy/log/test.log +1519 -0
  54. data/spec/examples.txt +79 -0
  55. data/spec/models/lab_tech/default_cleaner_spec.rb +32 -0
  56. data/spec/models/lab_tech/experiment_spec.rb +110 -0
  57. data/spec/models/lab_tech/percentile_spec.rb +85 -0
  58. data/spec/models/lab_tech/result_spec.rb +198 -0
  59. data/spec/models/lab_tech/speedup_spec.rb +133 -0
  60. data/spec/models/lab_tech/summary_spec.rb +325 -0
  61. data/spec/models/lab_tech_spec.rb +23 -0
  62. data/spec/rails_helper.rb +62 -0
  63. data/spec/spec_helper.rb +98 -0
  64. data/spec/support/misc_helpers.rb +7 -0
  65. metadata +238 -0
@@ -0,0 +1,183 @@
1
+ module LabTech
2
+ class Summary
3
+ TAB = " " * 4
4
+ LINE = "-" * 80
5
+ VAL = "█"
6
+ DOT = "·"
7
+
8
+ def initialize(experiment)
9
+ @experiment = experiment
10
+ end
11
+
12
+ def to_s
13
+ if experiment.results.count.zero?
14
+ return [ LINE, "No results for experiment #{@experiment.name.inspect}", LINE ].join("\n")
15
+ end
16
+
17
+ fetch_data
18
+
19
+ s = StringIO.new
20
+ s.puts LINE, "Experiment: #{@experiment.name}", LINE
21
+
22
+ add_time_span_to s
23
+ add_counts_to s
24
+
25
+ if @time_deltas.any?
26
+ add_time_deltas_to s
27
+ add_speedup_chart_to s
28
+ end
29
+
30
+ s.puts LINE
31
+ return s.string
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :experiment
37
+
38
+ def add_counts_to(s)
39
+ s.puts
40
+ summarize_count( s, :correct )
41
+ summarize_count( s, :mismatched )
42
+ summarize_count( s, :timeout, "timed out" )
43
+ summarize_count( s, :errored, "raised errors" )
44
+ end
45
+
46
+ def add_speedup_chart_to(s)
47
+ s.puts
48
+ 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
+ (0..100).step(5) do |n|
52
+ s.puts TAB + speedup_summary_line(n, speedup_magnitude)
53
+ end
54
+ end
55
+
56
+ def add_time_deltas_to(s)
57
+ percentile = ->(n) { "%+.3fs" % LabTech::Percentile.call(n, @time_deltas) }
58
+ s.puts
59
+ s << "Median time delta: #{percentile.(50)}"
60
+ s << " "
61
+ s << "(90% of observations between #{percentile.(5)} and #{percentile.(95)})"
62
+ s.puts
63
+ end
64
+
65
+ def add_time_span_to(s)
66
+ t0, t1 = @earliest_result, @latest_result
67
+ s.puts "Earliest results: #{ t0.iso8601 }"
68
+ s.puts "Latest result: #{ t1.iso8601 } (%s)" \
69
+ % date_helper.distance_of_time_in_words(t0, t1)
70
+ end
71
+
72
+ def date_helper
73
+ @_date_helper ||= Object.new.tap do |o|
74
+ o.extend ActionView::Helpers::DateHelper
75
+ end
76
+ end
77
+
78
+ def fetch_data
79
+ # Grab all aggregate operations counts/lists inside a transaction
80
+ # so all the counts are consistent
81
+ @experiment.transaction do
82
+ scope = experiment.results
83
+
84
+ @earliest_result = scope.minimum(:created_at)
85
+ @latest_result = scope.maximum(:created_at)
86
+
87
+ @counts = {
88
+ results: scope.count,
89
+ correct: scope.correct.count,
90
+ mismatched: scope.mismatched.count,
91
+ timeout: scope.timed_out.count,
92
+ errored: scope.other_error.count,
93
+ }
94
+
95
+ speedups = experiment.results.correct.pluck(:time_delta, :speedup_factor).map { |time, factor|
96
+ LabTech::Speedup.new(time: time, factor: factor)
97
+ }
98
+ @time_deltas = speedups.map(&:time).compact.sort
99
+ @speedup_factors = speedups.map(&:factor).compact.sort
100
+ end
101
+ end
102
+
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
+ def summarize_count(s, count_name, label = nil)
174
+ count = @counts[count_name]
175
+ return if count.zero?
176
+
177
+ total = @counts[:results]
178
+ label ||= count_name.to_s
179
+ s.puts "%s of %s (%s) %s" % [ humanize( count ), humanize( total ), rate( count ), label ]
180
+ end
181
+
182
+ end
183
+ end
@@ -0,0 +1,2 @@
1
+ LabTech::Engine.routes.draw do
2
+ end
@@ -0,0 +1,50 @@
1
+ class CreateExperimentTables < ActiveRecord::Migration[5.1]
2
+ def change
3
+
4
+ # Quick E-R diagram:
5
+ #
6
+ # +------------+ +--------+ +-------------+
7
+ # | Experiment |----E| Result |----E| Observation |
8
+ # +------------+ +--------+ +-------------+
9
+
10
+ create_table "lab_tech_experiments" do |t|
11
+ t.string "name"
12
+ t.integer "percent_enabled", null: false, default: 0
13
+ t.integer "equivalent_count", null: false, default: 0
14
+ t.integer "timed_out_count", null: false, default: 0
15
+ t.integer "other_error_count", null: false, default: 0
16
+
17
+ t.index [ "name" ], unique: true, name: "index_lab_tech_experiments_by_name"
18
+ end
19
+
20
+ create_table "lab_tech_results" do |t|
21
+ t.integer "experiment_id", null: false
22
+ t.text "context"
23
+ t.boolean "equivalent", null: false, default: false
24
+ t.boolean "raised_error", null: false, default: false
25
+ t.float "time_delta", limit: 24
26
+ t.float "speedup_factor", limit: 24
27
+ t.datetime "created_at"
28
+ t.boolean "timed_out", null: false, default: false
29
+ t.float "control_duration", limit: 24
30
+ t.float "candidate_duration", limit: 24
31
+
32
+ t.index [ "experiment_id", "equivalent" ], name: "index_lab_tech_results_by_exp_id_and_equivalent"
33
+ t.index [ "experiment_id", "raised_error" ], name: "index_lab_tech_results_by_exp_id_and_raised"
34
+ end
35
+
36
+ create_table "lab_tech_observations" do |t|
37
+ t.integer "result_id", null: false
38
+ t.string "name", limit: 100
39
+ t.float "duration", limit: 24
40
+ t.text "value", limit: 4294967295
41
+ t.text "sql"
42
+ t.string "exception_class"
43
+ t.text "exception_message"
44
+ t.text "exception_backtrace"
45
+ t.datetime "created_at"
46
+
47
+ t.index [ "result_id" ], name: "index_lab_tech_observations_by_result_id"
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,176 @@
1
+ require "lab_tech/engine"
2
+ require "scientist"
3
+
4
+ module LabTech
5
+ extend self
6
+
7
+ ########################################################################
8
+ #
9
+ # So, you've come here for science? EXCELLENT.
10
+ #
11
+ # TL;DR:
12
+ #
13
+ # LabTech.science "experiment-name" do |exp|
14
+ # exp.use { STABLE_CODE } # this is the "control"
15
+ # exp.try { BETTER_CODE } # this is the "candidate"
16
+ #
17
+ # # Optional, but often useful:
18
+ # exp.context foo: "spam", bar: "eggs", yak: "bacon"
19
+ # exp.compare {|control, candidate| control.map(&:id) == candidate.map(&:id) }
20
+ # exp.clean { |records| records.map(&:id) }
21
+ # end
22
+ #
23
+ # See https://github.com/github/scientist for an *extremely* detailed
24
+ # README that explains how to use this. For those purposes, the thing
25
+ # passed to the block as `exp` is a Scientist::Experiment.
26
+ #
27
+ # NOTE: You'll probably want to check out the .enable and .disable methods
28
+ # below if you want your candidate code to actually *run*...
29
+ #
30
+ ########################################################################
31
+ def science(experiment_name, opts = {}, &block)
32
+ experiment = Experiment.named( experiment_name )
33
+
34
+ yield experiment
35
+
36
+ test = opts[:run] if opts # TODO: figure out what this line was supposed to be for ¯\_(ツ)_/¯
37
+ experiment.run(test)
38
+ end
39
+
40
+ ########################################################################
41
+ #
42
+ # This here is how you turn individual experiments on and off...
43
+ #
44
+ ########################################################################
45
+ def self.enable(*experiment_names, percent: 100)
46
+ experiments_named( experiment_names ) do |exp|
47
+ exp.enable percent_enabled: percent
48
+ end
49
+ end
50
+
51
+ def self.disable(*experiment_names)
52
+ experiments_named( experiment_names, &:disable )
53
+ end
54
+
55
+ ########################################################################
56
+ #
57
+ # ...with an additional step if you want to record results in the Rails
58
+ # test environment.
59
+ #
60
+ ########################################################################
61
+ def self.publish_results_in_test_mode? ; !!@publish_results_in_test_mode ; end
62
+ def self.publish_results_in_test_mode=(value) ; @publish_results_in_test_mode = !!value ; end
63
+ def self.publish_results_in_test_mode
64
+ fail ArgumentError, "a block is required for this method" unless block_given?
65
+
66
+ old_value = self.publish_results_in_test_mode?
67
+ self.publish_results_in_test_mode = true
68
+ yield
69
+ ensure
70
+ self.publish_results_in_test_mode = old_value
71
+ end
72
+
73
+ ########################################################################
74
+ #
75
+ # You'll probably want to see how your experiments are doing...
76
+ #
77
+ ########################################################################
78
+ def self.summarize_results(*experiment_names)
79
+ experiments_named( experiment_names, &:summarize_results )
80
+ end
81
+
82
+ ########################################################################
83
+ #
84
+ # ...and be annoyed when they're not 100% correct...
85
+ #
86
+ ########################################################################
87
+ #
88
+ # By default, this will simply print the values of all mismatches.
89
+ # However, if you'd like to pass a block that returns arguments to
90
+ # IO#puts, you can probably get more useful results.
91
+ #
92
+ # Here's one example based on an experiment that records the IDs
93
+ # returned from a search:
94
+ #
95
+ # comparison = ->(cont, cand) {
96
+ # cont_ids, cand_ids = cont.value, cand.value
97
+ # case
98
+ # when cont_ids == cand_ids ; "EVERYTHING IS FINE" # if this were true, it wouldn't be a mismatch
99
+ # when cont_ids.sort == cand_ids.sort ; "ORDER DIFFERS"
100
+ # else
101
+ # [
102
+ # "CONTROL length: #{ cont_ids.length }",
103
+ # "CANDIDATE length: #{ cand_ids.length }",
104
+ # " missing: #{ (cont_ids - cand_ids).inspect }",
105
+ # " extra: #{ (cand_ids - cont_ids).inspect }",
106
+ # ]
107
+ # end
108
+ # }
109
+ # e = Experiment.named "isolate-lead-activities-in-lead-search"
110
+ # e.compare_mismatches limit: 10, &comparison
111
+ #
112
+ # And here's another one that assumes you've recorded a hash of the form:
113
+ # { ids: [ 1, 2, ... ], sql: "SELECT FROM ..." }
114
+ #
115
+ # comparison = ->(cont, cand) {
116
+ # cont_ids, cand_ids = cont.value.fetch(:ids), cand.value.fetch(:ids)
117
+ # cont_sql, cand_sql = cont.value.fetch(:sql), cand.value.fetch(:sql)
118
+ # sql_strings = [ "", "CONTROL SQL", cont_sql, "", "CANDIDATE SQL", cand_sql ]
119
+ # case
120
+ # when cont_ids == cand_ids ; "EVERYTHING IS FINE" # if this were true, it wouldn't be a mismatch
121
+ # when cont_ids.sort == cand_ids.sort ; [ "ORDER DIFFERS" ] + sql_strings
122
+ # else
123
+ # [
124
+ # "CONTROL length: #{ cont_ids.length }",
125
+ # "CANDIDATE length: #{ cand_ids.length }",
126
+ # " missing: #{ (cont_ids - cand_ids).inspect }",
127
+ # " extra: #{ (cand_ids - cont_ids).inspect }",
128
+ # ] + sql_strings
129
+ # end
130
+ # }
131
+ # e = Experiment.named "isolate-lead-activities-in-lead-search"
132
+ # e.compare_mismatches limit: 10, &comparison
133
+ #
134
+ ########################################################################
135
+ def self.compare_mismatches(experiment_name, limit: nil, io: $stdout, &block)
136
+ exp = LabTech::Experiment.named( experiment_name )
137
+ exp.compare_mismatches limit: limit, io: io, &block
138
+ end
139
+
140
+ ########################################################################
141
+ #
142
+ # ...and be curious about the errors...
143
+ #
144
+ ########################################################################
145
+ def self.summarize_errors(experiment_name, limit: nil, io: $stdout)
146
+ exp = LabTech::Experiment.named( experiment_name )
147
+ exp.summarize_errors( limit: limit, io: io )
148
+ end
149
+
150
+ ########################################################################
151
+ #
152
+ # Sometimes specs might want to see that an experiment ran, the silly paranoid things
153
+ #
154
+ ########################################################################
155
+ def self.reset_run_count!
156
+ run_count.clear
157
+ end
158
+ def self.run_count
159
+ @_experiment_run_count ||= Hash.new(0)
160
+ end
161
+
162
+
163
+ ########################################################################
164
+ #
165
+ # Sometimes we want to act on a batch of experiments
166
+ # (this is mostly just plumbing; feel free to ignore it)
167
+ #
168
+ ########################################################################
169
+ def self.experiments_named(*experiment_names, &block)
170
+ names = experiment_names.flatten.compact
171
+ names.each do |exp_name|
172
+ LabTech::Experiment.named(exp_name, &block)
173
+ end
174
+ end
175
+
176
+ end
@@ -0,0 +1,6 @@
1
+ module LabTech
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace LabTech
4
+ config.generators.api_only = true
5
+ end
6
+ end
@@ -0,0 +1,3 @@
1
+ module LabTech
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :lab_tech do
3
+ # # Task goes here
4
+ # end
@@ -0,0 +1,6 @@
1
+ # Add your own tasks in files placed in lib/tasks ending in .rake,
2
+ # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
3
+
4
+ require_relative 'config/application'
5
+
6
+ Rails.application.load_tasks
@@ -0,0 +1 @@
1
+ //= link_directory ../stylesheets .css
@@ -0,0 +1,14 @@
1
+ // This is a manifest file that'll be compiled into application.js, which will include all the files
2
+ // listed below.
3
+ //
4
+ // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts,
5
+ // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path.
6
+ //
7
+ // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
8
+ // compiled file. JavaScript code in this file should be added after the last require_* statement.
9
+ //
10
+ // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details
11
+ // about supported directives.
12
+ //
13
+ //= require rails-ujs
14
+ //= require_tree .