lab_tech 0.1.0

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.
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 .