Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ability to allocate traffic for variant by weights #20

Merged
merged 7 commits into from
Oct 29, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion src/alephbet.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,32 @@ class AlephBet
@storage().get("#{@options.name}:variant")

pick_variant: ->
# we are checking that all variants of experiment has weights
all_variants_have_weights = utils.checkWeights(@variants)
utils.log("all variants has weight: #{all_variants_have_weights}")
# if all variants has weights than we should fire version for variants with weight
if all_variants_have_weights then @pick_weighted_variant() else @pick_unweighted_variant()

pick_weighted_variant: ->

# Choosing a weighted variant:
# For A, B, C with weights 10, 30, 60
# variants = A, B, C
# weights = 10, 30, 60
# weights_sum = 100 (sum of weights)
# weighted_index = 21 (random number between 0 and weight sum)
# ABBBCCCCCC - (every letter occurence should by multiplied by 10)
# =======^
# Select C
weights_sum = utils.sumWeights(@variants)
weighted_index = Math.floor((@_random('variant') * weights_sum) + 1 )
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why + 1 here?

for key, value of @variants
# then we are substracting variant weight from selected number
# and it it reaches 0 (or below) we are selecting this variant
weighted_index -= value.weight
return key if weighted_index <= 0

pick_unweighted_variant: ->
partitions = 1.0 / @variant_names.length
chosen_partition = Math.floor(@_random('variant') / partitions)
variant = @variant_names[chosen_partition]
Expand Down Expand Up @@ -99,7 +125,8 @@ class AlephBet
throw 'an experiment name must be specified' if @options.name is null
throw 'variants must be provided' if @options.variants is null
throw 'trigger must be a function' if typeof @options.trigger isnt 'function'

every_variant_has_weight = utils.validateWeights @options.variants
throw 'not all variants contains weight' if !every_variant_has_weight

class @Goal
constructor: (@name, @props={}) ->
Expand Down
13 changes: 13 additions & 0 deletions src/utils.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,17 @@ class Utils
return Math.random() unless seed
# a MUCH simplified version inspired by PlanOut.js
parseInt(@sha1(seed).substr(0, 13), 16) / 0xFFFFFFFFFFFFF
@checkWeights: (variants) ->
contains_weight = []
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would personally write this as

variants_with_weights = variants.filter((variant) -> variant.weight).length
variants_with_weights == variants.length

contains_weight.push(value.weight?) for key, value of variants
contains_weight.every (has_weight) -> has_weight
@sumWeights: (variants) ->
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would probably express this as

variants.reduce ((total, variant) -> total + (variant.weight || 0)), 0

it's slightly more dense, but it's "just" a sum of weights...

sum = 0
for key, value of variants
sum += value.weight if value.weight?
sum
@validateWeights: (variants) ->
contains_weight = []
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again, it's a matter of personal preference, but I would find it clearer to write something like

variants_with_weights = variants.filter((variant) -> variant.weight).length
return true if variants_with_weights == 0 || variants.length == variants_with_weights
false

(then you can also re-use variants_with_weights as a shared function

contains_weight.push(value.weight?) for key, value of variants
contains_weight.every (has_weight) -> has_weight or contains_weight.every (has_weight) -> !has_weight
module.exports = Utils
28 changes: 28 additions & 0 deletions test/alephbet_test.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,31 @@ describe 'tracks non unique goals', (t) ->
t.assert(tracking.goal_complete.callCount == 2, 'goal_complete was called twice')
t.notOk(storage.get('with-goals:my goal'), 'goal not stored')

describe 'when all variants has weights', (t) ->
ex = experiment({
name: 'with-weights',
variants:
blue:
weight: 0
activate: activate
green:
weight: 100
activate: activate
})
t.plan(2)
t.assert(ex.pick_variant() == 'green', 'always picks green variant')
t.assert(ex.pick_variant() != 'blue', 'never picks blue variant')

describe 'when only some variants has weights', (t) ->
try ex = experiment({
name: 'not-all-weights',
variants:
blue:
activate: activate
green:
weight: 100
activate: activate
})
catch e then t.assert(true, 'creating experiment should throw error')
t.plan(2)
t.assert(activate.callCount == 0, 'activate function shouldn\'t be called')
71 changes: 71 additions & 0 deletions test/utils_test.coffee
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
test = require('tape')
sinon = require('sinon')
_ = require('lodash')
Utils = require('../src/utils')

setup = ->
utils = new Utils();

describe = (description, fn) ->
test description, (t) ->
setup()
fn(t)

describe 'utils - checkWeights', (t) ->
variants = {
a:
weight: 50
b:
weight: 50
}
all_has_weight = Utils.checkWeights(variants);
variants = {
a: {}
b: {}
}
none_has_weight = Utils.checkWeights(variants);
variants = {
a:
weight: 20
b: {}
}
some_has_weight = Utils.checkWeights(variants);
t.plan(3)
t.assert(all_has_weight == true, 'all variants contains weight')
t.assert(none_has_weight == false, 'variants does not contain weights')
t.assert(some_has_weight == false, 'only some variants does not have weight')

describe 'utils - sumWeights', (t) ->
variants = {
a:
weight: 55
b:
weight: 35
}
sum = Utils.sumWeights(variants);
t.plan(1)
t.assert(sum == 90, 'sum should be equal 90')

describe 'utils - validateWeights', (t) ->
variants = {
a:
weight: 55
b:
weight: 35
}
all_valid = Utils.validateWeights(variants);
variants = {
a:
weight: 55
b: {}
}
some_valid = Utils.validateWeights(variants);
variants = {
a: {}
b: {}
}
all_not_have_weight = Utils.validateWeights(variants);
t.plan(3)
t.assert(all_valid == true, 'all should be valid')
t.assert(some_valid == false, 'only some are valid')
t.assert(all_not_have_weight == true, 'all does not contain weight but are valid')