vanity 0.2.0 → 0.2.1
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.
- data/CHANGELOG +7 -1
- data/README.rdoc +1 -1
- data/lib/vanity/experiment/ab_test.rb +154 -35
- data/lib/vanity/experiment/base.rb +80 -21
- data/test/ab_test_test.rb +167 -7
- data/test/experiment_test.rb +4 -1
- data/vanity.gemspec +1 -1
- metadata +3 -4
- data/test/ab_test_template.erb +0 -3
    
        data/CHANGELOG
    CHANGED
    
    | @@ -1,4 +1,10 @@ | |
| 1 | 
            -
            0. | 
| 1 | 
            +
            0.2.1 (2009-11-11)
         | 
| 2 | 
            +
            * Added: z-score and confidence level for A/B test alternatives.
         | 
| 3 | 
            +
            * Added: test auto-completion and auto-outcome (complete_it, outcome_is).
         | 
| 4 | 
            +
            * Changed: default alternatives are now false/true, so if can't decide
         | 
| 5 | 
            +
            outcome, fall back on false.
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            0.2.0 (2009-11-10)
         | 
| 2 8 | 
             
            * Added: experiment method on object, used to define and access experiments.
         | 
| 3 9 | 
             
            * Added: playground configuration (Vanity.playground.namespace = , etc).
         | 
| 4 10 | 
             
            * Added: use_vanity now accepts block instead of symbol.
         | 
    
        data/README.rdoc
    CHANGED
    
    
| @@ -31,6 +31,15 @@ module Vanity | |
| 31 31 | 
             
                    redis.get(key("conversions")).to_i
         | 
| 32 32 | 
             
                  end
         | 
| 33 33 |  | 
| 34 | 
            +
                  # Conversion rate calculated as converted/participants.
         | 
| 35 | 
            +
                  def conversion_rate
         | 
| 36 | 
            +
                    converted.to_f / participants.to_f
         | 
| 37 | 
            +
                  end
         | 
| 38 | 
            +
             | 
| 39 | 
            +
                  def <=>(other)
         | 
| 40 | 
            +
                    conversion_rate <=> other.conversion_rate 
         | 
| 41 | 
            +
                  end
         | 
| 42 | 
            +
             | 
| 34 43 | 
             
                  def participating!(identity)
         | 
| 35 44 | 
             
                    redis.sadd key("participants"), identity
         | 
| 36 45 | 
             
                  end
         | 
| @@ -42,6 +51,33 @@ module Vanity | |
| 42 51 | 
             
                    end
         | 
| 43 52 | 
             
                  end
         | 
| 44 53 |  | 
| 54 | 
            +
                  # Z-score this alternativet related to the base alternative.  This
         | 
| 55 | 
            +
                  # alternative is better than base if it receives a positive z-score,
         | 
| 56 | 
            +
                  # worse if z-score is negative.  Call #confident if you need confidence
         | 
| 57 | 
            +
                  # level (percentage).
         | 
| 58 | 
            +
                  def z_score
         | 
| 59 | 
            +
                    return 0 if base == self
         | 
| 60 | 
            +
                    pc = base.conversion_rate
         | 
| 61 | 
            +
                    nc = base.participants
         | 
| 62 | 
            +
                    p = conversion_rate
         | 
| 63 | 
            +
                    n = participants
         | 
| 64 | 
            +
                    (p - pc) / Math.sqrt((p * (1-p)/n) + (pc * (1-pc)/nc))
         | 
| 65 | 
            +
                  end
         | 
| 66 | 
            +
             | 
| 67 | 
            +
                  # How confident are we in this alternative being an improvement over the
         | 
| 68 | 
            +
                  # base alternative.  Returns 0, 90, 95, 99 or 99.9 (percentage).
         | 
| 69 | 
            +
                  def confidence
         | 
| 70 | 
            +
                    score = z_score
         | 
| 71 | 
            +
                    confidence = AbTest::Z_TO_CONFIDENCE.find { |z,p| score >= z }
         | 
| 72 | 
            +
                    confidence ? confidence.last : 0
         | 
| 73 | 
            +
                  end
         | 
| 74 | 
            +
             | 
| 75 | 
            +
                  def destroy #:nodoc:
         | 
| 76 | 
            +
                    redis.del key("participants")
         | 
| 77 | 
            +
                    redis.del key("converted")
         | 
| 78 | 
            +
                    redis.del key("conversions")
         | 
| 79 | 
            +
                  end
         | 
| 80 | 
            +
             | 
| 45 81 | 
             
                protected
         | 
| 46 82 |  | 
| 47 83 | 
             
                  def key(name)
         | 
| @@ -52,39 +88,20 @@ module Vanity | |
| 52 88 | 
             
                    @experiment.redis
         | 
| 53 89 | 
             
                  end
         | 
| 54 90 |  | 
| 91 | 
            +
                  def base
         | 
| 92 | 
            +
                    @base ||= @experiment.alternatives.first
         | 
| 93 | 
            +
                  end
         | 
| 94 | 
            +
             | 
| 55 95 | 
             
                end
         | 
| 56 96 |  | 
| 97 | 
            +
             | 
| 57 98 | 
             
                # The meat.
         | 
| 58 99 | 
             
                class AbTest < Base
         | 
| 59 100 | 
             
                  def initialize(*args) #:nodoc:
         | 
| 60 101 | 
             
                    super
         | 
| 61 102 | 
             
                  end
         | 
| 62 103 |  | 
| 63 | 
            -
                  #  | 
| 64 | 
            -
                  #
         | 
| 65 | 
            -
                  # This method returns different values for different identity (see
         | 
| 66 | 
            -
                  # #identify), and consistenly the same value for the same
         | 
| 67 | 
            -
                  # expriment/identity pair.
         | 
| 68 | 
            -
                  #
         | 
| 69 | 
            -
                  # For example:
         | 
| 70 | 
            -
                  #   color = experiment(:which_blue).choose
         | 
| 71 | 
            -
                  def choose
         | 
| 72 | 
            -
                    identity = identify
         | 
| 73 | 
            -
                    alt = alternative_for(identity)
         | 
| 74 | 
            -
                    alt.participating! identity
         | 
| 75 | 
            -
                    alt.value
         | 
| 76 | 
            -
                  end
         | 
| 77 | 
            -
             | 
| 78 | 
            -
                  # Records a conversion.
         | 
| 79 | 
            -
                  #
         | 
| 80 | 
            -
                  # For example:
         | 
| 81 | 
            -
                  #   experiment(:which_blue).conversion!
         | 
| 82 | 
            -
                  def conversion!
         | 
| 83 | 
            -
                    identity = identify
         | 
| 84 | 
            -
                    alt = alternative_for(identity)
         | 
| 85 | 
            -
                    alt.conversion! identity
         | 
| 86 | 
            -
                    alt.id
         | 
| 87 | 
            -
                  end
         | 
| 104 | 
            +
                  # -- Alternatives --
         | 
| 88 105 |  | 
| 89 106 | 
             
                  # Call this method once to specify values for the A/B test.  At least two
         | 
| 90 107 | 
             
                  # values are required.
         | 
| @@ -99,7 +116,7 @@ module Vanity | |
| 99 116 | 
             
                  #   alts = experiment(:background_color).alternatives
         | 
| 100 117 | 
             
                  #   puts "#{alts.count} alternatives, with the colors: #{alts.map(&:value).join(", ")}"
         | 
| 101 118 | 
             
                  def alternatives(*args)
         | 
| 102 | 
            -
                    args = [ | 
| 119 | 
            +
                    args = [false, true] if args.empty?
         | 
| 103 120 | 
             
                    @alternatives = []
         | 
| 104 121 | 
             
                    args.each_with_index do |arg, i|
         | 
| 105 122 | 
             
                      @alternatives << Alternative.new(self, i, arg)
         | 
| @@ -108,18 +125,50 @@ module Vanity | |
| 108 125 | 
             
                    alternatives
         | 
| 109 126 | 
             
                  end
         | 
| 110 127 |  | 
| 111 | 
            -
                  # Sets this test to two alternatives:  | 
| 112 | 
            -
                  def  | 
| 113 | 
            -
                    alternatives  | 
| 128 | 
            +
                  # Sets this test to two alternatives: false and true.
         | 
| 129 | 
            +
                  def false_true
         | 
| 130 | 
            +
                    alternatives false, true
         | 
| 114 131 | 
             
                  end
         | 
| 132 | 
            +
                  alias true_false false_true
         | 
| 115 133 |  | 
| 116 | 
            -
                   | 
| 117 | 
            -
             | 
| 118 | 
            -
             | 
| 119 | 
            -
             | 
| 120 | 
            -
             | 
| 134 | 
            +
                  # Chooses a value for this experiment.
         | 
| 135 | 
            +
                  #
         | 
| 136 | 
            +
                  # This method returns different values for different identity (see
         | 
| 137 | 
            +
                  # #identify), and consistenly the same value for the same
         | 
| 138 | 
            +
                  # expriment/identity pair.
         | 
| 139 | 
            +
                  #
         | 
| 140 | 
            +
                  # For example:
         | 
| 141 | 
            +
                  #   color = experiment(:which_blue).choose
         | 
| 142 | 
            +
                  def choose
         | 
| 143 | 
            +
                    if active?
         | 
| 144 | 
            +
                      identity = identify
         | 
| 145 | 
            +
                      alt = alternative_for(identity)
         | 
| 146 | 
            +
                      alt.participating! identity
         | 
| 147 | 
            +
                      check_completion!
         | 
| 148 | 
            +
                      alt.value
         | 
| 149 | 
            +
                    elsif alternative = outcome
         | 
| 150 | 
            +
                      alternative.value
         | 
| 151 | 
            +
                    else
         | 
| 152 | 
            +
                      alternatives.first.value
         | 
| 153 | 
            +
                    end
         | 
| 154 | 
            +
                  end
         | 
| 155 | 
            +
             | 
| 156 | 
            +
                  # Records a conversion.
         | 
| 157 | 
            +
                  #
         | 
| 158 | 
            +
                  # For example:
         | 
| 159 | 
            +
                  #   experiment(:which_blue).conversion!
         | 
| 160 | 
            +
                  def conversion!
         | 
| 161 | 
            +
                    if active?
         | 
| 162 | 
            +
                      identity = identify
         | 
| 163 | 
            +
                      alt = alternative_for(identity)
         | 
| 164 | 
            +
                      alt.conversion! identity
         | 
| 165 | 
            +
                      check_completion!
         | 
| 166 | 
            +
                    end
         | 
| 121 167 | 
             
                  end
         | 
| 122 168 |  | 
| 169 | 
            +
                  
         | 
| 170 | 
            +
                  # -- Testing --
         | 
| 171 | 
            +
                 
         | 
| 123 172 | 
             
                  # Forces this experiment to use a particular alternative. Useful for
         | 
| 124 173 | 
             
                  # tests, e.g.
         | 
| 125 174 | 
             
                  #
         | 
| @@ -142,15 +191,78 @@ module Vanity | |
| 142 191 | 
             
                    Vanity.context.session[:vanity][id] = alternative.id
         | 
| 143 192 | 
             
                  end
         | 
| 144 193 |  | 
| 194 | 
            +
             | 
| 195 | 
            +
                  # -- Reporting --
         | 
| 196 | 
            +
             | 
| 197 | 
            +
                  def report
         | 
| 198 | 
            +
                    alts = alternatives.map { |alt|
         | 
| 199 | 
            +
                      "<dt>Option #{(65 + alt.id).chr}</dt><dd><code>#{CGI.escape_html alt.value.inspect}</code> viewed #{alt.participants} times, converted #{alt.conversions}, rate #{alt.conversion_rate}, z_score #{alt.z_score}, confidence #{alt.confidence}<dd>"
         | 
| 200 | 
            +
                    }
         | 
| 201 | 
            +
                    %{<dl class="data">#{alts.join}</dl>}
         | 
| 202 | 
            +
                  end
         | 
| 203 | 
            +
             | 
| 145 204 | 
             
                  def humanize
         | 
| 146 205 | 
             
                    "A/B Test" 
         | 
| 147 206 | 
             
                  end
         | 
| 148 207 |  | 
| 208 | 
            +
             | 
| 209 | 
            +
                  # -- Completion --
         | 
| 210 | 
            +
             | 
| 211 | 
            +
                  # Defines how the experiment can choose the optimal outcome on completion.
         | 
| 212 | 
            +
                  #
         | 
| 213 | 
            +
                  # The default implementation looks for the best (highest conversion rate)
         | 
| 214 | 
            +
                  # alternative.  If it's certain (95% or more) that this alternative is
         | 
| 215 | 
            +
                  # better than the first alternative, it switches to that one.  If it has
         | 
| 216 | 
            +
                  # no such certainty, it starts using the first alternative exclusively.
         | 
| 217 | 
            +
                  #
         | 
| 218 | 
            +
                  # The default implementation reads like this:
         | 
| 219 | 
            +
                  #   outcome_is do
         | 
| 220 | 
            +
                  #     highest = alternatives.sort.last
         | 
| 221 | 
            +
                  #     highest.confidence >= 95 ? highest ? alternatives.first
         | 
| 222 | 
            +
                  #   end
         | 
| 223 | 
            +
                  def outcome_is(&block)
         | 
| 224 | 
            +
                    raise ArgumentError, "Missing block" unless block
         | 
| 225 | 
            +
                    raise "outcome_is already called on this experiment" if @outcome_is
         | 
| 226 | 
            +
                    @outcome_is = block
         | 
| 227 | 
            +
                  end
         | 
| 228 | 
            +
             | 
| 229 | 
            +
                  # Alternative chosen when this experiment was completed.
         | 
| 230 | 
            +
                  def outcome
         | 
| 231 | 
            +
                    outcome = redis.get(key("outcome"))
         | 
| 232 | 
            +
                    outcome && alternatives[outcome.to_i]
         | 
| 233 | 
            +
                  end
         | 
| 234 | 
            +
             | 
| 235 | 
            +
                  def complete! #:nodoc:
         | 
| 236 | 
            +
                    super
         | 
| 237 | 
            +
                    if @outcome_is
         | 
| 238 | 
            +
                      begin
         | 
| 239 | 
            +
                        outcome = alternatives.find_index(@outcome_is.call)
         | 
| 240 | 
            +
                      rescue
         | 
| 241 | 
            +
                        # TODO: logging
         | 
| 242 | 
            +
                      end
         | 
| 243 | 
            +
                    end
         | 
| 244 | 
            +
                    unless outcome
         | 
| 245 | 
            +
                      highest = alternatives.sort.last rescue nil
         | 
| 246 | 
            +
                      outcome = highest && highest.confidence >= 95 ? highest.id : 0
         | 
| 247 | 
            +
                    end
         | 
| 248 | 
            +
                    # TODO: logging
         | 
| 249 | 
            +
                    redis.setnx key("outcome"), outcome
         | 
| 250 | 
            +
                  end
         | 
| 251 | 
            +
             | 
| 252 | 
            +
                  
         | 
| 253 | 
            +
                  # -- Store/validate --
         | 
| 254 | 
            +
             | 
| 149 255 | 
             
                  def save #:nodoc:
         | 
| 150 256 | 
             
                    fail "Experiment #{name} needs at least two alternatives" unless alternatives.count >= 2
         | 
| 151 257 | 
             
                    super
         | 
| 152 258 | 
             
                  end
         | 
| 153 259 |  | 
| 260 | 
            +
                  def destroy #:nodoc:
         | 
| 261 | 
            +
                    redis.del key(:outcome)
         | 
| 262 | 
            +
                    alternatives.each(&:destroy)
         | 
| 263 | 
            +
                    super
         | 
| 264 | 
            +
                  end
         | 
| 265 | 
            +
             | 
| 154 266 | 
             
                private
         | 
| 155 267 |  | 
| 156 268 | 
             
                  # Chooses an alternative for the identity and returns its index. This
         | 
| @@ -164,7 +276,14 @@ module Vanity | |
| 164 276 | 
             
                    alternatives[index]
         | 
| 165 277 | 
             
                  end
         | 
| 166 278 |  | 
| 279 | 
            +
                  begin
         | 
| 280 | 
            +
                    a = 0
         | 
| 281 | 
            +
                    # Returns array of [z-score, percentage]
         | 
| 282 | 
            +
                    norm_dist = (-5.0..3.1).step(0.01).map { |x| [x, a += 1 / Math.sqrt(2 * Math::PI) * Math::E ** (-x ** 2 / 2)] }
         | 
| 283 | 
            +
                    # We're really only interested in 90%, 95%, 99% and 99.9%.
         | 
| 284 | 
            +
                    Z_TO_CONFIDENCE = [90, 95, 99, 99.9].map { |pct| [norm_dist.find { |x,a| a >= pct }.first, pct] }.reverse
         | 
| 285 | 
            +
                  end
         | 
| 286 | 
            +
             | 
| 167 287 | 
             
                end
         | 
| 168 288 | 
             
              end
         | 
| 169 289 | 
             
            end
         | 
| 170 | 
            -
             | 
| @@ -18,8 +18,8 @@ module Vanity | |
| 18 18 | 
             
                    @playground = playground
         | 
| 19 19 | 
             
                    @id, @name = id.to_sym, name
         | 
| 20 20 | 
             
                    @namespace = "#{@playground.namespace}:#{@id}"
         | 
| 21 | 
            -
                     | 
| 22 | 
            -
                    @created_at = Time.at( | 
| 21 | 
            +
                    redis.setnx key(:created_at), Time.now.to_i
         | 
| 22 | 
            +
                    @created_at = Time.at(redis.get(key(:created_at)).to_i)
         | 
| 23 23 | 
             
                    @identify_block = ->(context){ context.vanity_identity }
         | 
| 24 24 | 
             
                  end
         | 
| 25 25 |  | 
| @@ -31,26 +31,10 @@ module Vanity | |
| 31 31 |  | 
| 32 32 | 
             
                  # Experiment creation timestamp.
         | 
| 33 33 | 
             
                  attr_reader :created_at
         | 
| 34 | 
            -
                 
         | 
| 35 | 
            -
                  # Sets or returns description. For example
         | 
| 36 | 
            -
                  #   experiment :simple do
         | 
| 37 | 
            -
                  #     description "Simple experiment"
         | 
| 38 | 
            -
                  #   end
         | 
| 39 | 
            -
                  #
         | 
| 40 | 
            -
                  #   puts "Just defined: " + experiment(:simple).description
         | 
| 41 | 
            -
                  def description(text = nil)
         | 
| 42 | 
            -
                    @description = text if text
         | 
| 43 | 
            -
                    @description
         | 
| 44 | 
            -
                  end
         | 
| 45 | 
            -
             | 
| 46 | 
            -
                  def report
         | 
| 47 | 
            -
                    fail "Implement me"
         | 
| 48 | 
            -
                  end
         | 
| 49 | 
            -
                  
         | 
| 50 | 
            -
                  # Called to save the experiment definition.
         | 
| 51 | 
            -
                  def save #:nodoc:
         | 
| 52 | 
            -
                  end
         | 
| 53 34 |  | 
| 35 | 
            +
                  # Experiment completion timestamp.
         | 
| 36 | 
            +
                  attr_reader :completed_at
         | 
| 37 | 
            +
                 
         | 
| 54 38 | 
             
                  # Call this method with no argument or block to return an identity.  Call
         | 
| 55 39 | 
             
                  # this method with a block to define how to obtain an identity for the
         | 
| 56 40 | 
             
                  # current experiment.
         | 
| @@ -80,6 +64,70 @@ module Vanity | |
| 80 64 | 
             
                    end
         | 
| 81 65 | 
             
                  end
         | 
| 82 66 |  | 
| 67 | 
            +
             | 
| 68 | 
            +
                  # -- Reporting --
         | 
| 69 | 
            +
             | 
| 70 | 
            +
                  # Sets or returns description. For example
         | 
| 71 | 
            +
                  #   experiment :simple do
         | 
| 72 | 
            +
                  #     description "Simple experiment"
         | 
| 73 | 
            +
                  #   end
         | 
| 74 | 
            +
                  #
         | 
| 75 | 
            +
                  #   puts "Just defined: " + experiment(:simple).description
         | 
| 76 | 
            +
                  def description(text = nil)
         | 
| 77 | 
            +
                    @description = text if text
         | 
| 78 | 
            +
                    @description
         | 
| 79 | 
            +
                  end
         | 
| 80 | 
            +
             | 
| 81 | 
            +
                  def report
         | 
| 82 | 
            +
                    fail "Implement me"
         | 
| 83 | 
            +
                  end
         | 
| 84 | 
            +
                  
         | 
| 85 | 
            +
             | 
| 86 | 
            +
                  # -- Experiment completion --
         | 
| 87 | 
            +
             | 
| 88 | 
            +
                  # Define experiment completion condition.  For example:
         | 
| 89 | 
            +
                  #   complete_if do
         | 
| 90 | 
            +
                  #     alternatives.all? { |alt| alt.participants >= 100 } &&
         | 
| 91 | 
            +
                  #     alternatives.any? { |alt| alt.confidence >= 0.95 }
         | 
| 92 | 
            +
                  #   end
         | 
| 93 | 
            +
                  def complete_if(&block)
         | 
| 94 | 
            +
                    raise ArgumentError, "Missing block" unless block
         | 
| 95 | 
            +
                    raise "complete_if already called on this experiment" if @complete_block
         | 
| 96 | 
            +
                    @complete_block = block
         | 
| 97 | 
            +
                  end
         | 
| 98 | 
            +
             | 
| 99 | 
            +
                  # Derived classes call this after state changes that may lead to
         | 
| 100 | 
            +
                  # experiment completing.
         | 
| 101 | 
            +
                  def check_completion!
         | 
| 102 | 
            +
                    if @complete_block
         | 
| 103 | 
            +
                      begin
         | 
| 104 | 
            +
                        complete! if @complete_block.call
         | 
| 105 | 
            +
                      rescue
         | 
| 106 | 
            +
                        # TODO: logging
         | 
| 107 | 
            +
                      end
         | 
| 108 | 
            +
                    end
         | 
| 109 | 
            +
                  end
         | 
| 110 | 
            +
                  protected :check_completion!
         | 
| 111 | 
            +
             | 
| 112 | 
            +
                  # Force experiment to complete.
         | 
| 113 | 
            +
                  def complete!
         | 
| 114 | 
            +
                    redis.setnx key(:completed_at), Time.now.to_i
         | 
| 115 | 
            +
                    # TODO: logging
         | 
| 116 | 
            +
                  end
         | 
| 117 | 
            +
             | 
| 118 | 
            +
                  # Time stamp when experiment was completed.
         | 
| 119 | 
            +
                  def completed_at
         | 
| 120 | 
            +
                    Time.at(redis.get(key(:completed_at)).to_i)
         | 
| 121 | 
            +
                  end
         | 
| 122 | 
            +
                  
         | 
| 123 | 
            +
                  # Returns true if experiment active, false if completed.
         | 
| 124 | 
            +
                  def active?
         | 
| 125 | 
            +
                    redis.get(key(:completed_at)).nil?
         | 
| 126 | 
            +
                  end
         | 
| 127 | 
            +
             | 
| 128 | 
            +
             | 
| 129 | 
            +
                  # -- Store/validate --
         | 
| 130 | 
            +
             | 
| 83 131 | 
             
                  # Returns key for this experiment, or with an argument, return a key
         | 
| 84 132 | 
             
                  # using the experiment as the namespace.  Examples:
         | 
| 85 133 | 
             
                  #   key => "vanity:experiments:green_button"
         | 
| @@ -92,6 +140,17 @@ module Vanity | |
| 92 140 | 
             
                  def redis #:nodoc:
         | 
| 93 141 | 
             
                    @playground.redis
         | 
| 94 142 | 
             
                  end
         | 
| 143 | 
            +
                  
         | 
| 144 | 
            +
                  # Called to save the experiment definition.
         | 
| 145 | 
            +
                  def save #:nodoc:
         | 
| 146 | 
            +
                  end
         | 
| 147 | 
            +
             | 
| 148 | 
            +
                  # Get rid of all experiment data.
         | 
| 149 | 
            +
                  def destroy
         | 
| 150 | 
            +
                    redis.del key(:created_at)
         | 
| 151 | 
            +
                    redis.del key(:completed_at)
         | 
| 152 | 
            +
                  end
         | 
| 153 | 
            +
             | 
| 95 154 | 
             
                end
         | 
| 96 155 | 
             
              end
         | 
| 97 156 | 
             
            end
         | 
    
        data/test/ab_test_test.rb
    CHANGED
    
    | @@ -13,7 +13,7 @@ class AbTestController < ActionController::Base | |
| 13 13 | 
             
              end
         | 
| 14 14 |  | 
| 15 15 | 
             
              def test_capture
         | 
| 16 | 
            -
                render  | 
| 16 | 
            +
                render inline: "<% ab_test :simple_ab do |value| %><%= value %><% end %>"
         | 
| 17 17 | 
             
              end
         | 
| 18 18 |  | 
| 19 19 | 
             
              def goal
         | 
| @@ -29,7 +29,8 @@ class AbTestTest < ActionController::TestCase | |
| 29 29 | 
             
                experiment(:simple_ab) { }
         | 
| 30 30 | 
             
              end
         | 
| 31 31 |  | 
| 32 | 
            -
             | 
| 32 | 
            +
             | 
| 33 | 
            +
              # --  Experiment definition --
         | 
| 33 34 |  | 
| 34 35 | 
             
              def uses_ab_test_when_type_is_ab_test
         | 
| 35 36 | 
             
                experiment(:ab, type: :ab_test) { }
         | 
| @@ -52,7 +53,8 @@ class AbTestTest < ActionController::TestCase | |
| 52 53 | 
             
                end
         | 
| 53 54 | 
             
              end
         | 
| 54 55 |  | 
| 55 | 
            -
             | 
| 56 | 
            +
             | 
| 57 | 
            +
              # -- Running experiment --
         | 
| 56 58 |  | 
| 57 59 | 
             
              def returns_the_same_alternative_consistently
         | 
| 58 60 | 
             
                experiment :foobar do
         | 
| @@ -122,7 +124,7 @@ class AbTestTest < ActionController::TestCase | |
| 122 124 | 
             
              end
         | 
| 123 125 |  | 
| 124 126 |  | 
| 125 | 
            -
              # A/B helper methods
         | 
| 127 | 
            +
              # -- A/B helper methods --
         | 
| 126 128 |  | 
| 127 129 | 
             
              def test_fail_if_no_experiment
         | 
| 128 130 | 
             
                new_playground
         | 
| @@ -167,7 +169,7 @@ class AbTestTest < ActionController::TestCase | |
| 167 169 | 
             
              end
         | 
| 168 170 |  | 
| 169 171 |  | 
| 170 | 
            -
              # Testing with tests
         | 
| 172 | 
            +
              # -- Testing with tests --
         | 
| 171 173 |  | 
| 172 174 | 
             
              def test_with_given_choice
         | 
| 173 175 | 
             
                100.times do
         | 
| @@ -177,8 +179,8 @@ class AbTestTest < ActionController::TestCase | |
| 177 179 | 
             
                  post :goal
         | 
| 178 180 | 
             
                end
         | 
| 179 181 | 
             
                alts = experiment(:simple_ab).alternatives
         | 
| 180 | 
            -
                assert_equal [100 | 
| 181 | 
            -
                assert_equal [100 | 
| 182 | 
            +
                assert_equal [0,100], alts.map { |alt| alt.participants }
         | 
| 183 | 
            +
                assert_equal [0,100], alts.map { |alt| alt.conversions }
         | 
| 182 184 | 
             
              end
         | 
| 183 185 |  | 
| 184 186 | 
             
              def test_which_chooses_non_existent_alternative
         | 
| @@ -187,4 +189,162 @@ class AbTestTest < ActionController::TestCase | |
| 187 189 | 
             
                end
         | 
| 188 190 | 
             
              end
         | 
| 189 191 |  | 
| 192 | 
            +
             | 
| 193 | 
            +
              # -- Z-score --
         | 
| 194 | 
            +
              
         | 
| 195 | 
            +
              def test_z_score
         | 
| 196 | 
            +
                experiment :abcd do
         | 
| 197 | 
            +
                  alternatives :a, :b, :c, :d
         | 
| 198 | 
            +
                end
         | 
| 199 | 
            +
                alts = experiment(:abcd).alternatives
         | 
| 200 | 
            +
                # participating, conversions, rate, z-score
         | 
| 201 | 
            +
                # Control:      182	35 19.23%	N/A
         | 
| 202 | 
            +
                182.times { |i| alts[0].participating!(i) }
         | 
| 203 | 
            +
                35.times { |i| alts[0].conversion!(i) }
         | 
| 204 | 
            +
                # Treatment A:  180	45 25.00%	1.33
         | 
| 205 | 
            +
                180.times { |i| alts[1].participating!(i + 200) }
         | 
| 206 | 
            +
                45.times { |i| alts[1].conversion!(i + 200) }
         | 
| 207 | 
            +
                # Treatment B:  189	28 14.81%	-1.13
         | 
| 208 | 
            +
                189.times { |i| alts[2].participating!(i + 400) }
         | 
| 209 | 
            +
                28.times { |i| alts[2].conversion!(i + 400) }
         | 
| 210 | 
            +
                # Treatment C:  188	61 32.45%	2.94
         | 
| 211 | 
            +
                188.times { |i| alts[3].participating!(i + 600) }
         | 
| 212 | 
            +
                61.times { |i| alts[3].conversion!(i + 600) }
         | 
| 213 | 
            +
             | 
| 214 | 
            +
                z_scores = alts.map { |alt| sprintf("%4.2f", alt.z_score) }
         | 
| 215 | 
            +
                assert_equal %w{0.00 1.33 -1.13 2.94}, z_scores
         | 
| 216 | 
            +
             | 
| 217 | 
            +
                confidences = alts.map { |alt| alt.confidence }
         | 
| 218 | 
            +
                assert_equal [0, 90, 0, 99], confidences
         | 
| 219 | 
            +
              end
         | 
| 220 | 
            +
              
         | 
| 221 | 
            +
             | 
| 222 | 
            +
              # -- Completion --
         | 
| 223 | 
            +
             | 
| 224 | 
            +
              def test_completion_if
         | 
| 225 | 
            +
                experiment :simple do
         | 
| 226 | 
            +
                  identify { rand }
         | 
| 227 | 
            +
                  complete_if { true }
         | 
| 228 | 
            +
                end
         | 
| 229 | 
            +
                experiment(:simple).choose
         | 
| 230 | 
            +
                refute experiment(:simple).active?
         | 
| 231 | 
            +
              end
         | 
| 232 | 
            +
             | 
| 233 | 
            +
              def test_completion_if_fails
         | 
| 234 | 
            +
                experiment :simple do
         | 
| 235 | 
            +
                  identify { rand }
         | 
| 236 | 
            +
                  complete_if { fail }
         | 
| 237 | 
            +
                end
         | 
| 238 | 
            +
                experiment(:simple).choose
         | 
| 239 | 
            +
                assert experiment(:simple).active?
         | 
| 240 | 
            +
              end
         | 
| 241 | 
            +
             | 
| 242 | 
            +
              def test_completion
         | 
| 243 | 
            +
                ids = Array.new(100) { |i| i.to_s }.shuffle
         | 
| 244 | 
            +
                experiment :simple do
         | 
| 245 | 
            +
                  identify { ids.pop }
         | 
| 246 | 
            +
                  complete_if { alternatives.map(&:participants).sum >= 100 }
         | 
| 247 | 
            +
                end
         | 
| 248 | 
            +
                99.times do |i|
         | 
| 249 | 
            +
                  experiment(:simple).choose
         | 
| 250 | 
            +
                  assert experiment(:simple).active?
         | 
| 251 | 
            +
                end
         | 
| 252 | 
            +
             | 
| 253 | 
            +
                experiment(:simple).choose
         | 
| 254 | 
            +
                refute experiment(:simple).active?
         | 
| 255 | 
            +
              end
         | 
| 256 | 
            +
             | 
| 257 | 
            +
              def test_ab_methods_after_completion
         | 
| 258 | 
            +
                ids = Array.new(200) { |i| i.to_s }.shuffle
         | 
| 259 | 
            +
                test = self
         | 
| 260 | 
            +
                experiment :simple do
         | 
| 261 | 
            +
                  identify { test.identity ||= ids.pop }
         | 
| 262 | 
            +
                  complete_if { alternatives.map(&:participants).sum >= 100 }
         | 
| 263 | 
            +
                  outcome_is { alternatives[1] }
         | 
| 264 | 
            +
                end
         | 
| 265 | 
            +
                # Run experiment to completion (100 participants)
         | 
| 266 | 
            +
                results = Set.new
         | 
| 267 | 
            +
                100.times do
         | 
| 268 | 
            +
                  test.identity = nil
         | 
| 269 | 
            +
                  results << experiment(:simple).choose
         | 
| 270 | 
            +
                  experiment(:simple).conversion!
         | 
| 271 | 
            +
                end
         | 
| 272 | 
            +
                assert results.include?(true) && results.include?(false)
         | 
| 273 | 
            +
                refute experiment(:simple).active?
         | 
| 274 | 
            +
             | 
| 275 | 
            +
                # Test that we always get the same choice (true)
         | 
| 276 | 
            +
                100.times do
         | 
| 277 | 
            +
                  test.identity = nil
         | 
| 278 | 
            +
                  assert_equal true, experiment(:simple).choose
         | 
| 279 | 
            +
                  experiment(:simple).conversion!
         | 
| 280 | 
            +
                end
         | 
| 281 | 
            +
                # We don't get to count the 100 participant's conversion, but that's ok.
         | 
| 282 | 
            +
                assert_equal 99, experiment(:simple).alternatives.map(&:converted).sum
         | 
| 283 | 
            +
                assert_equal 99, experiment(:simple).alternatives.map(&:conversions).sum
         | 
| 284 | 
            +
              end
         | 
| 285 | 
            +
             | 
| 286 | 
            +
             | 
| 287 | 
            +
              # -- Outcome --
         | 
| 288 | 
            +
              
         | 
| 289 | 
            +
              def test_completion_outcome
         | 
| 290 | 
            +
                experiment :quick do
         | 
| 291 | 
            +
                  outcome_is { alternatives[1] }
         | 
| 292 | 
            +
                end
         | 
| 293 | 
            +
                experiment(:quick).complete!
         | 
| 294 | 
            +
                assert_equal experiment(:quick).alternatives[1], experiment(:quick).outcome
         | 
| 295 | 
            +
              end
         | 
| 296 | 
            +
             | 
| 297 | 
            +
              def test_outcome_is_returns_nil
         | 
| 298 | 
            +
                experiment :quick do
         | 
| 299 | 
            +
                  outcome_is { nil }
         | 
| 300 | 
            +
                end
         | 
| 301 | 
            +
                experiment(:quick).complete!
         | 
| 302 | 
            +
                assert_equal experiment(:quick).alternatives.first, experiment(:quick).outcome
         | 
| 303 | 
            +
              end
         | 
| 304 | 
            +
             | 
| 305 | 
            +
              def test_outcome_is_returns_something_else
         | 
| 306 | 
            +
                experiment :quick do
         | 
| 307 | 
            +
                  outcome_is { "error" }
         | 
| 308 | 
            +
                end
         | 
| 309 | 
            +
                experiment(:quick).complete!
         | 
| 310 | 
            +
                assert_equal experiment(:quick).alternatives.first, experiment(:quick).outcome
         | 
| 311 | 
            +
              end
         | 
| 312 | 
            +
             | 
| 313 | 
            +
              def test_outcome_is_fails
         | 
| 314 | 
            +
                experiment :quick do
         | 
| 315 | 
            +
                  outcome_is { fail }
         | 
| 316 | 
            +
                end
         | 
| 317 | 
            +
                experiment(:quick).complete!
         | 
| 318 | 
            +
                assert_equal experiment(:quick).alternatives.first, experiment(:quick).outcome
         | 
| 319 | 
            +
              end
         | 
| 320 | 
            +
             | 
| 321 | 
            +
              def test_outcome_choosing_best_alternative
         | 
| 322 | 
            +
                experiment :quick do
         | 
| 323 | 
            +
                end
         | 
| 324 | 
            +
                2.times do |i|
         | 
| 325 | 
            +
                  experiment(:quick).alternatives[0].participating!(i)
         | 
| 326 | 
            +
                end
         | 
| 327 | 
            +
                10.times do |i|
         | 
| 328 | 
            +
                  experiment(:quick).alternatives[1].participating!(i)
         | 
| 329 | 
            +
                  experiment(:quick).alternatives[1].conversion!(i)
         | 
| 330 | 
            +
                end
         | 
| 331 | 
            +
                experiment(:quick).complete!
         | 
| 332 | 
            +
                assert_equal experiment(:quick).alternatives[1], experiment(:quick).outcome
         | 
| 333 | 
            +
              end
         | 
| 334 | 
            +
             | 
| 335 | 
            +
              def test_outcome_choosing_first_alternative
         | 
| 336 | 
            +
                experiment :quick do
         | 
| 337 | 
            +
                end
         | 
| 338 | 
            +
                8.times do |i|
         | 
| 339 | 
            +
                  experiment(:quick).alternatives[0].participating!(i)
         | 
| 340 | 
            +
                  experiment(:quick).alternatives[0].conversion!(i)
         | 
| 341 | 
            +
                end
         | 
| 342 | 
            +
                7.times do |i|
         | 
| 343 | 
            +
                  experiment(:quick).alternatives[1].participating!(i)
         | 
| 344 | 
            +
                  experiment(:quick).alternatives[1].conversion!(i)
         | 
| 345 | 
            +
                end
         | 
| 346 | 
            +
                experiment(:quick).complete!
         | 
| 347 | 
            +
                assert_equal experiment(:quick).alternatives[0], experiment(:quick).outcome
         | 
| 348 | 
            +
              end
         | 
| 349 | 
            +
             | 
| 190 350 | 
             
            end
         | 
    
        data/test/experiment_test.rb
    CHANGED
    
    | @@ -27,11 +27,13 @@ class ExperimentTest < MiniTest::Spec | |
| 27 27 | 
             
              end
         | 
| 28 28 |  | 
| 29 29 | 
             
              it "keeps creation timestamp across definitions" do
         | 
| 30 | 
            -
                early = Time.now - 1.day
         | 
| 30 | 
            +
                early, late = Time.now - 1.day, Time.now
         | 
| 31 31 | 
             
                Time.expects(:now).once.returns(early)
         | 
| 32 32 | 
             
                experiment(:simple) { }
         | 
| 33 33 | 
             
                assert_equal early.to_i, experiment(:simple).created_at.to_i
         | 
| 34 | 
            +
             | 
| 34 35 | 
             
                new_playground
         | 
| 36 | 
            +
                Time.expects(:now).once.returns(late)
         | 
| 35 37 | 
             
                experiment(:simple) { }
         | 
| 36 38 | 
             
                assert_equal early.to_i, experiment(:simple).created_at.to_i
         | 
| 37 39 | 
             
              end
         | 
| @@ -42,4 +44,5 @@ class ExperimentTest < MiniTest::Spec | |
| 42 44 | 
             
                end
         | 
| 43 45 | 
             
                assert_equal "Simple experiment", experiment(:simple).description
         | 
| 44 46 | 
             
              end
         | 
| 47 | 
            +
             | 
| 45 48 | 
             
            end
         | 
    
        data/vanity.gemspec
    CHANGED
    
    
    
        metadata
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            --- !ruby/object:Gem::Specification 
         | 
| 2 2 | 
             
            name: vanity
         | 
| 3 3 | 
             
            version: !ruby/object:Gem::Version 
         | 
| 4 | 
            -
              version: 0.2. | 
| 4 | 
            +
              version: 0.2.1
         | 
| 5 5 | 
             
            platform: ruby
         | 
| 6 6 | 
             
            authors: 
         | 
| 7 7 | 
             
            - Assaf Arkin
         | 
| @@ -9,7 +9,7 @@ autorequire: | |
| 9 9 | 
             
            bindir: bin
         | 
| 10 10 | 
             
            cert_chain: []
         | 
| 11 11 |  | 
| 12 | 
            -
            date: 2009-11- | 
| 12 | 
            +
            date: 2009-11-11 00:00:00 -08:00
         | 
| 13 13 | 
             
            default_executable: 
         | 
| 14 14 | 
             
            dependencies: 
         | 
| 15 15 | 
             
            - !ruby/object:Gem::Dependency 
         | 
| @@ -40,7 +40,6 @@ files: | |
| 40 40 | 
             
            - lib/vanity/rails.rb
         | 
| 41 41 | 
             
            - lib/vanity/report.erb
         | 
| 42 42 | 
             
            - lib/vanity.rb
         | 
| 43 | 
            -
            - test/ab_test_template.erb
         | 
| 44 43 | 
             
            - test/ab_test_test.rb
         | 
| 45 44 | 
             
            - test/experiment_test.rb
         | 
| 46 45 | 
             
            - test/playground_test.rb
         | 
| @@ -56,7 +55,7 @@ licenses: [] | |
| 56 55 | 
             
            post_install_message: 
         | 
| 57 56 | 
             
            rdoc_options: 
         | 
| 58 57 | 
             
            - --title
         | 
| 59 | 
            -
            - Vanity 0.2. | 
| 58 | 
            +
            - Vanity 0.2.1
         | 
| 60 59 | 
             
            - --main
         | 
| 61 60 | 
             
            - README.rdoc
         | 
| 62 61 | 
             
            - --webcvs
         | 
    
        data/test/ab_test_template.erb
    DELETED