conductor 0.8.3 → 0.9.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. data/{LICENSE → MIT-LICENSE} +1 -1
  2. data/README.rdoc +7 -0
  3. data/Rakefile +25 -45
  4. data/app/assets/javascripts/conductor/application.js +15 -0
  5. data/app/assets/javascripts/conductor/dashboard.js +2 -0
  6. data/app/assets/stylesheets/conductor/application.css +13 -0
  7. data/{generators/conductor/templates/conductor.css → app/assets/stylesheets/conductor/dashboard.css} +0 -0
  8. data/app/controllers/conductor/application_controller.rb +4 -0
  9. data/app/controllers/conductor/dashboard_controller.rb +11 -0
  10. data/app/helpers/conductor/application_helper.rb +4 -0
  11. data/{lib/conductor/rails/helpers → app/helpers/conductor}/dashboard_helper.rb +3 -1
  12. data/app/models/conductor/experiment/daily.rb +47 -0
  13. data/app/models/conductor/experiment/history.rb +9 -0
  14. data/app/models/conductor/experiment/raw.rb +20 -0
  15. data/app/models/conductor/experiment/weight.rb +11 -0
  16. data/{lib/conductor/rails/views → app/views/conductor}/dashboard/_current_weights.html.haml +0 -0
  17. data/{lib/conductor/rails/views → app/views/conductor}/dashboard/_daily_stats.html.haml +0 -0
  18. data/{lib/conductor/rails/views → app/views/conductor}/dashboard/_group_stats.html.haml +0 -0
  19. data/{lib/conductor/rails/views → app/views/conductor}/dashboard/_top_nav.html.haml +0 -0
  20. data/{lib/conductor/rails/views → app/views/conductor}/dashboard/_weight_history.html.haml +0 -0
  21. data/app/views/conductor/dashboard/index.html.haml +12 -0
  22. data/app/views/layouts/conductor/application.html.erb +14 -0
  23. data/config/routes.rb +4 -0
  24. data/db/migrate/20121010131323_create_conductor_daily_experiments.rb +16 -0
  25. data/db/migrate/20121010173406_create_conductor_experiment_weights.rb +13 -0
  26. data/db/migrate/20121010174835_create_conductor_raw_experiments.rb +13 -0
  27. data/db/migrate/20121010174950_create_conductor_weight_histories.rb +14 -0
  28. data/lib/conductor.rb +33 -57
  29. data/lib/conductor/core_ext.rb +18 -0
  30. data/lib/conductor/engine.rb +5 -0
  31. data/lib/conductor/experiment.rb +61 -60
  32. data/lib/conductor/roll_up.rb +2 -2
  33. data/lib/conductor/version.rb +3 -0
  34. data/lib/conductor/weights.rb +9 -8
  35. data/lib/tasks/conductor_tasks.rake +9 -0
  36. data/test/core_ext_test.rb +13 -0
  37. data/test/fixtures/conductor/conductor_daily_experiments.yml +17 -0
  38. data/test/functional/conductor/dashboard_controller_test.rb +11 -0
  39. data/test/integration/conductor/dashboard_test.rb +7 -0
  40. data/test/integration/navigation_test.rb +10 -0
  41. data/test/test_helper.rb +9 -23
  42. data/test/unit/conductor/conductor_daily_experiment_test.rb +9 -0
  43. data/test/unit/helpers/conductor/dashboard_helper_test.rb +6 -0
  44. metadata +240 -91
  45. data/VERSION +0 -1
  46. data/generators/conductor/conductor_generator.rb +0 -27
  47. data/generators/conductor/templates/conductor.rake +0 -6
  48. data/generators/conductor/templates/migration.rb +0 -54
  49. data/init.rb +0 -2
  50. data/lib/conductor/rails/controllers/dashboard.rb +0 -16
  51. data/lib/conductor/rails/models/daily.rb +0 -53
  52. data/lib/conductor/rails/models/history.rb +0 -14
  53. data/lib/conductor/rails/models/raw.rb +0 -27
  54. data/lib/conductor/rails/models/weight.rb +0 -18
  55. data/lib/conductor/rails/views/dashboard/index.html.haml +0 -29
  56. data/rails/init.rb +0 -7
  57. data/test/db/schema.rb +0 -43
  58. data/test/test_conductor.rb +0 -346
@@ -1,4 +1,4 @@
1
- Copyright (c) 2009 Noctivity
1
+ Copyright 2012 YOURNAME
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
@@ -1,3 +1,10 @@
1
+ = MASSIVE UPGRADE TO RAILS ENGINE
2
+
3
+ If anyone is using this with Rails < 3 you need to specify branch 0.8.3
4
+
5
+ For all using 3 and above use branch 0.9.3
6
+
7
+
1
8
  = conductor
2
9
 
3
10
  Conductor is the bastard child of a/b testing and personalization.
data/Rakefile CHANGED
@@ -1,57 +1,37 @@
1
- require 'rubygems'
2
- require 'rake'
3
-
1
+ #!/usr/bin/env rake
4
2
  begin
5
- require 'jeweler'
6
- Jeweler::Tasks.new do |gem|
7
- gem.name = "conductor"
8
- gem.summary = %Q{lets you just try things while always maximizing towards a goal (e.g. purchase, signups, etc)}
9
- gem.description = %Q{Conductor is the bastard child of a/b testing and personalization. It throws everything you know about creating a web site our the window and lets you just "try stuff" without ever having to worry about not maximing your site's "purpose." Have a new landing page? Just throw it to the conductor. Want to try different price points - conductor. Different form designs? Conductor. Conductor will rotate all alternatives through the mix and eventually settle on the top performing of all, without you having to do anything other than just creating. Think "intelligent A/B testing" on steriods.}
10
- gem.email = "jlippiner@noctivity.com"
11
- gem.homepage = "http://github.com/noctivityinc/conductor"
12
- gem.authors = ["Noctivity"]
13
- gem.rubyforge_project = "conductor"
14
- gem.files = FileList["[A-Z]*", "{generators,lib,tasks,rails}/**/*", "init.rb"]
15
- gem.add_dependency 'googlecharts'
16
- gem.add_dependency 'haml'
17
- # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
18
- end
19
- Jeweler::GemcutterTasks.new
3
+ require 'bundler/setup'
20
4
  rescue LoadError
21
- puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
22
- end
23
-
24
- require 'rake/testtask'
25
- Rake::TestTask.new(:test) do |test|
26
- test.libs << 'lib' << 'test'
27
- test.pattern = 'test/**/test_*.rb'
28
- test.verbose = true
5
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
29
6
  end
30
-
31
7
  begin
32
- require 'rcov/rcovtask'
33
- Rcov::RcovTask.new do |test|
34
- test.libs << 'test'
35
- test.pattern = 'test/**/test_*.rb'
36
- test.verbose = true
37
- end
8
+ require 'rdoc/task'
38
9
  rescue LoadError
39
- task :rcov do
40
- abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
41
- end
10
+ require 'rdoc/rdoc'
11
+ require 'rake/rdoctask'
12
+ RDoc::Task = Rake::RDocTask
13
+ end
14
+
15
+ RDoc::Task.new(:rdoc) do |rdoc|
16
+ rdoc.rdoc_dir = 'rdoc'
17
+ rdoc.title = 'Conductor'
18
+ rdoc.options << '--line-numbers'
19
+ rdoc.rdoc_files.include('README.rdoc')
20
+ rdoc.rdoc_files.include('lib/**/*.rb')
42
21
  end
43
22
 
44
- task :test => :check_dependencies
23
+ APP_RAKEFILE = File.expand_path("../spec/dummy/Rakefile", __FILE__)
24
+ load 'rails/tasks/engine.rake'
45
25
 
46
- task :default => :test
26
+ Bundler::GemHelper.install_tasks
47
27
 
48
- require 'rake/rdoctask'
49
- Rake::RDocTask.new do |rdoc|
50
- version = File.exist?('VERSION') ? File.read('VERSION') : ""
28
+ require 'rake/testtask'
51
29
 
52
- rdoc.rdoc_dir = 'rdoc'
53
- rdoc.title = "conductor #{version}"
54
- rdoc.rdoc_files.include('README*')
55
- rdoc.rdoc_files.include('lib/**/*.rb')
30
+ Rake::TestTask.new(:test) do |t|
31
+ t.libs << 'lib'
32
+ t.libs << 'test'
33
+ t.pattern = 'test/**/*_test.rb'
34
+ t.verbose = false
56
35
  end
57
36
 
37
+ task :default => :test
@@ -0,0 +1,15 @@
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 vendor/assets/javascripts of plugins, if any, 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
+ // the compiled file.
9
+ //
10
+ // WARNING: THE FIRST BLANK LINE MARKS THE END OF WHAT'S TO BE PROCESSED, ANY BLANK LINE SHOULD
11
+ // GO AFTER THE REQUIRES BELOW.
12
+ //
13
+ //= require jquery
14
+ //= require jquery_ujs
15
+ //= require_tree .
@@ -0,0 +1,2 @@
1
+ // Place all the behaviors and hooks related to the matching controller here.
2
+ // All this logic will automatically be available in application.js.
@@ -0,0 +1,13 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the top of the
9
+ * compiled file, but it's generally better to create a new file per style scope.
10
+ *
11
+ *= require_self
12
+ *= require_tree .
13
+ */
@@ -0,0 +1,4 @@
1
+ module Conductor
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -0,0 +1,11 @@
1
+ require_dependency "conductor/application_controller"
2
+
3
+ module Conductor
4
+ class DashboardController < ApplicationController
5
+ def index
6
+ @weights = Conductor::Experiment::Weight.all
7
+ @weight_history = Conductor::Experiment::History.all
8
+ @dailies = Conductor::Experiment::Daily.all
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,4 @@
1
+ module Conductor
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -1,4 +1,6 @@
1
- module DashboardHelper
1
+ require 'googlecharts'
2
+
3
+ module Conductor::DashboardHelper
2
4
  def current_weights(group_name, group)
3
5
  total = group.inject(0) {|res, x| res += x.weight}
4
6
  data = []
@@ -0,0 +1,47 @@
1
+ module Conductor
2
+ class Experiment
3
+
4
+ class Daily < ActiveRecord::Base
5
+ self.table_name = "conductor_daily_experiments"
6
+
7
+ attr_accessible :activity_date, :conversion_value, :conversions, :group_name, :option_name, :views, :alternative
8
+
9
+ scope :since, lambda { |a_date| { :conditions => ['activity_date >= ?',a_date] }}
10
+ scope :for_group, lambda { |group_name| { :conditions => ['group_name = ?',group_name] }}
11
+
12
+ def self.find_equalization_period_stats_for(group_name, alternatives=nil)
13
+ alternative_filter = alternatives ? alternatives.inject([]) {|res,x| res << "alternative = '#{Conductor.sanitize(x)}'"}.join(' OR ') : 'true'
14
+
15
+ sql = "SELECT alternative, min(activity_date) AS activity_date
16
+ FROM conductor_daily_experiments
17
+ WHERE group_name = '#{group_name}'
18
+ AND (#{alternative_filter})
19
+ GROUP BY alternative
20
+ HAVING min(activity_date) > '#{Date.today - Conductor.equalization_period}'"
21
+
22
+ self.find_by_sql(sql)
23
+ end
24
+
25
+
26
+ def self.find_post_equalization_period_stats_for(group_name, alternatives=nil)
27
+ alternative_filter = alternatives ? alternatives.inject([]) {|res,x| res << "alternative = '#{Conductor.sanitize(x)}'"}.join(' OR ') : 'true'
28
+
29
+ sql = "SELECT alternative, min(activity_date) AS activity_date, sum(views) AS views, sum(conversions) AS conversions, sum(conversion_value) AS conversion_value
30
+ FROM conductor_daily_experiments
31
+ WHERE group_name = '#{group_name}'
32
+ AND (#{alternative_filter})
33
+ AND activity_date >=
34
+ (SELECT max(min_date) FROM
35
+ (SELECT alternative, min(activity_date) AS min_date
36
+ FROM conductor_daily_experiments
37
+ WHERE activity_date >= '#{Date.today - Conductor.inclusion_period}'
38
+ GROUP BY alternative) AS a)
39
+ GROUP BY alternative
40
+ HAVING min(activity_date) <= '#{Date.today - Conductor.equalization_period}'"
41
+
42
+ self.find_by_sql(sql)
43
+ end
44
+
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,9 @@
1
+ module Conductor
2
+ class Experiment
3
+ class History < ActiveRecord::Base
4
+ self.table_name = 'conductor_weight_histories'
5
+
6
+ attr_accessible :computed_at, :group_name, :option_name, :weight, :alternative, :launch_window
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,20 @@
1
+ module Conductor
2
+ class Experiment
3
+ class Raw < ActiveRecord::Base
4
+ self.table_name = 'conductor_raw_experiments'
5
+
6
+ attr_accessible :conversion_value, :group_name, :identity_id, :option_name, :alternative, :created_at, :updated_at, :goal
7
+
8
+ validates_presence_of :group_name, :alternative
9
+ scope :since, lambda { |a_date| { :conditions => ['created_at >= ?',a_date] }}
10
+
11
+ def created_date
12
+ self.created_at.strftime('%Y-%m-%d')
13
+ end
14
+
15
+ def self.purge(days_old=30)
16
+ Conductor::Experiment::Raw.delete_all("created_at <= #{days_old.days.ago}")
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,11 @@
1
+ module Conductor
2
+ class Experiment
3
+ class Weight < ActiveRecord::Base
4
+ self.table_name = 'conductor_weighted_experiments'
5
+ attr_accessible :group_name, :option_name, :weight, :alternative, :created_at, :updated_at
6
+
7
+ scope :for_group, lambda { |group_name| { :conditions => ['group_name = ?',group_name] }}
8
+ scope :with_alternative, lambda { |alternative| { :conditions => ['alternative = ?',alternative] }}
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,12 @@
1
+ #conductor_dashboard
2
+ .title
3
+ %h1 Conductor Statistics
4
+ .generated= "Generated on #{Time.now}"
5
+
6
+ .top_nav= render :file => 'conductor/dashboard/_top_nav'
7
+ #weights= render :file => 'conductor/dashboard/_current_weights'
8
+ #group_stats= render :file => 'conductor/dashboard/_group_stats'
9
+ #dailies= render :file => 'conductor/dashboard/_daily_stats'
10
+ #history= render :file => 'conductor/dashboard/_weight_history'
11
+
12
+
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Conductor</title>
5
+ <%= stylesheet_link_tag "conductor/application", :media => "all" %>
6
+ <%= javascript_include_tag "conductor/application" %>
7
+ <%= csrf_meta_tags %>
8
+ </head>
9
+ <body>
10
+
11
+ <%= yield %>
12
+
13
+ </body>
14
+ </html>
@@ -0,0 +1,4 @@
1
+ Conductor::Engine.routes.draw do
2
+ match '/conductor', :to => "dashboard#index", :as => "conductor_dashboard"
3
+ root :to => "dashboard#index"
4
+ end
@@ -0,0 +1,16 @@
1
+ class CreateConductorDailyExperiments < ActiveRecord::Migration
2
+ def change
3
+ create_table "conductor_daily_experiments", :force => true do |t|
4
+ t.date "activity_date"
5
+ t.string "group_name"
6
+ t.string "alternative"
7
+ t.decimal "conversion_value", :precision => 8, :scale => 2
8
+ t.integer "views"
9
+ t.integer "conversions"
10
+ end
11
+
12
+ add_index "conductor_daily_experiments", ["activity_date"], :name => "index_conductor_daily_experiments_on_activity_date"
13
+ add_index "conductor_daily_experiments", ["group_name"], :name => "index_conductor_daily_experiments_on_group_name"
14
+
15
+ end
16
+ end
@@ -0,0 +1,13 @@
1
+ class CreateConductorExperimentWeights < ActiveRecord::Migration
2
+ def change
3
+ create_table "conductor_weighted_experiments", :force => true do |t|
4
+ t.string "group_name"
5
+ t.string "alternative"
6
+ t.decimal "weight", :precision => 8, :scale => 2
7
+ t.datetime "created_at"
8
+ t.datetime "updated_at"
9
+ end
10
+
11
+ add_index "conductor_weighted_experiments", ["group_name"], :name => "index_conductor_weighted_experiments_on_group_name"
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ class CreateConductorRawExperiments < ActiveRecord::Migration
2
+ def change
3
+ create_table "conductor_raw_experiments", :force => true do |t|
4
+ t.string "identity_id"
5
+ t.string "group_name"
6
+ t.string "alternative"
7
+ t.decimal "conversion_value", :precision => 8, :scale => 2
8
+ t.datetime "created_at"
9
+ t.datetime "updated_at"
10
+ t.string "goal"
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,14 @@
1
+ class CreateConductorWeightHistories < ActiveRecord::Migration
2
+ def change
3
+ create_table "conductor_weight_histories", :force => true do |t|
4
+ t.string "group_name"
5
+ t.string "alternative"
6
+ t.decimal "weight", :precision => 8, :scale => 2
7
+ t.datetime "computed_at"
8
+ t.integer "launch_window"
9
+ end
10
+
11
+ add_index "conductor_weight_histories", ["computed_at", "group_name"], :name => "conductor_wh_date_and_group_ndx"
12
+
13
+ end
14
+ end
@@ -1,27 +1,37 @@
1
- class Conductor
1
+ require "conductor/engine"
2
+ require 'conductor/core_ext'
3
+ require 'conductor/experiment'
4
+ require 'conductor/roll_up'
5
+ require 'conductor/weights'
6
+ require 'haml'
7
+
8
+ module Conductor
2
9
  MAX_WEIGHTING_FACTOR = 1.25
3
10
  EQUALIZATION_PERIOD_DEFAULT = 7
4
- MINIMUM_CONVERSIONS_PER_GROUP_DEFAULT = 10
5
11
  DBG = false
6
12
 
7
- cattr_writer :cache
8
-
13
+ # attr_accessor :cache
14
+
9
15
  def self.cache
10
- @@cache || Rails.cache
16
+ $cache || Rails.cache
11
17
  end
12
18
 
13
19
  class << self
14
- # Specifies a unique identity for the current visitor. If no identity is specified
15
- # then a random value is selected. Conductor makes sure that the same visitor
20
+ # Specifies a unique identity for the current visitor. If no identity is specified
21
+ # then a random value is selected. Conductor makes sure that the same visitor
16
22
  # will always see the same alternative selections to reduce confusion.
17
23
  def identity=(value)
18
24
  @conductor_identity = value
19
25
  end
20
26
 
21
27
  def identity
22
- return (@conductor_identity || ActiveSupport::SecureRandom.hex(16))
28
+ return (@conductor_identity || SecureRandom.hex(16))
29
+ end
30
+
31
+ def reset_identity
32
+ @conductor_identity = SecureRandom.hex(16)
23
33
  end
24
-
34
+
25
35
  # The number of days to include when calculating weights
26
36
  # The inclusion period MUST be higher than then equalization period
27
37
  # The default is 14 days
@@ -30,51 +40,38 @@ class Conductor
30
40
  raise "Conductor.inclusion_period must be greater than the equalization period" if value < equalization_period
31
41
  @inclusion_period = value
32
42
  end
33
-
43
+
34
44
  def inclusion_period
35
45
  return (@inclusion_period || 14)
36
46
  end
37
-
38
- # The minimum number of conversions that a group needs to have in TOTAL before
39
- # weighting is allowed.
40
- #
41
- # TODO: trigger a notification if a post equalized group hits below this number
42
- def minimum_conversions_per_group=(value)
43
- raise "Conductor.minimum_conversions_per_group must be a positive number > 0" unless value.is_a?(Numeric) && value > 0
44
- @minimum_conversions_per_group = value
45
- end
46
-
47
- def minimum_conversions_per_group
48
- return (@minimum_conversions_per_group || MINIMUM_CONVERSIONS_PER_GROUP_DEFAULT)
49
- end
50
-
51
- # The equalization period is the initial amount of time, in days, that conductor
52
- # should apply the max_weighting_factor towards a new alternative to ensure
47
+
48
+ # The equalization period is the initial amount of time, in days, that conductor
49
+ # should apply the max_weighting_factor towards a new alternative to ensure
53
50
  # that it receives a far shot of performing.
54
51
  #
55
- # If an equalization period was not used then any new alternative would
56
- # immediately be weighed very low since it has no conversions and would
52
+ # If an equalization period was not used then any new alternative would
53
+ # immediately be weighed very low since it has no conversions and would
57
54
  # never have a chance of performing
58
55
  def equalization_period=(value)
59
56
  raise "Conductor.equalization_period must be a positive number > 0" unless value.is_a?(Numeric) && value > 0
60
57
  @equalization_period = value
61
58
  end
62
-
59
+
63
60
  def equalization_period
64
61
  return (@equalization_period || EQUALIZATION_PERIOD_DEFAULT)
65
62
  end
66
-
67
- # The attribute for weighting specifies if the conversion_value OR number of conversions
68
- # should be used to calculate the weight. The default is conversions.
63
+
64
+ # The attribute for weighting specifies if the conversion_value OR number of conversions
65
+ # should be used to calculate the weight. The default is conversion_value.
69
66
  #
70
- # TODO: Change this to only use conversion rate when normalization is figured out
67
+ # TODO: Allow of avg_conversion_value where acv = conversion_value / conversions
71
68
  def attribute_for_weighting=(value)
72
- raise "Conductor.attribute_for_weighting must be either :views, :conversions or :conversion_value (default)" unless [:views, :conversions, :conversion_value].include?(value)
69
+ raise "Conductor.attribute_for_weighting must be either :views, :conversions or :conversion_value (default)" unless [:views, :conversions, :conversion_value].include?(value)
73
70
  @attribute_for_weighting = value
74
71
  end
75
-
72
+
76
73
  def attribute_for_weighting
77
- return (@attribute_for_weighting || :conversions)
74
+ return (@attribute_for_weighting || :conversion_value)
78
75
  end
79
76
 
80
77
  def log(msg)
@@ -85,25 +82,4 @@ class Conductor
85
82
  str.gsub(/\s/,'_').downcase
86
83
  end
87
84
  end
88
-
89
- end
90
-
91
-
92
- class Array
93
- def sum_it(attribute)
94
- self.map {|x| x.send(attribute) }.compact.sum
95
- end
96
-
97
- def weighted_mean_of_attribute(attribute)
98
- self.map {|x| x.send(attribute) }.compact.weighted_mean
99
- end
100
-
101
- def weighted_mean
102
- w_sum = sum(self)
103
- return 0.00 if w_sum == 0.00
104
-
105
- w_prod = 0
106
- self.each_index {|i| w_prod += (i+1) * self[i].to_f}
107
- w_prod.to_f / w_sum.to_f
108
- end
109
85
  end