├── python ├── planout │ ├── __init__.py │ ├── ops │ │ ├── __init__.py │ │ ├── utils.py │ │ ├── random.py │ │ └── base.py │ ├── test │ │ ├── __init__.py │ │ ├── test_assignment.py │ │ ├── test_namespace.py │ │ └── test_interpreter.py │ ├── assignment.py │ ├── interpreter.py │ └── namespace.py ├── docs │ ├── 00-getting-started.md │ ├── 08-about-planout.md │ ├── 04.1-extending-logging.md │ ├── 02-operators.md │ ├── 05-namespaces.md │ ├── 01-why-planout.md │ ├── 07-language.md │ ├── 05.1-simple-namespaces.md │ ├── 03-how-planout-works.md │ ├── 04-logging.md │ └── 06-best-practices.md └── setup.py ├── planout-editor ├── .gitignore ├── Procfile ├── static │ ├── fonts │ │ ├── glyphicons-halflings-regular.eot │ │ ├── glyphicons-halflings-regular.ttf │ │ └── glyphicons-halflings-regular.woff │ ├── planoutstyle.css │ └── js │ │ └── mode │ │ ├── javascript │ │ ├── typescript.html │ │ ├── json-ld.html │ │ ├── index.html │ │ └── test.js │ │ └── planout │ │ ├── typescript.html │ │ ├── json-ld.html │ │ ├── index.html │ │ └── test.js ├── requirements.txt ├── templates │ ├── index.html │ └── layout.html ├── js │ ├── dispatcher │ │ └── PlanOutEditorDispatcher.js │ ├── actions │ │ ├── PlanOutExperimentActions.js │ │ └── PlanOutTesterActions.js │ ├── app.js │ ├── utils │ │ ├── DemoData.js │ │ ├── PlanOutStaticAnalyzer.js │ │ └── PlanOutAsyncRequests.js │ ├── constants │ │ └── PlanOutEditorConstants.js │ ├── components │ │ ├── PlanOutTesterBoxOutput.react.js │ │ ├── PlanOutTesterPanel.react.js │ │ ├── PlanOutTesterBoxFormInput.react.js │ │ ├── PlanOutEditorButtons.react.js │ │ ├── PlanOutTesterBoxForm.react.js │ │ ├── PlanOutScriptPanel.react.js │ │ └── PlanOutTesterBox.react.js │ └── stores │ │ ├── PlanOutExperimentStore.js │ │ └── PlanOutTesterStore.js ├── package.json ├── README.md └── planout-editor-kernel.py ├── alpha └── ruby │ ├── lib │ ├── plan_out │ │ ├── version.rb │ │ ├── simple_experiment.rb │ │ ├── operator.rb │ │ ├── assignment.rb │ │ ├── op_random.rb │ │ └── experiment.rb │ └── plan_out.rb │ ├── test │ ├── test_helper.rb │ └── plan_out │ │ ├── operator_test.rb │ │ ├── assignment_test.rb │ │ └── experiment_test.rb │ ├── Rakefile │ ├── Gemfile │ ├── examples │ └── plan_out │ │ └── voting_experiment.rb │ ├── planout.gemspec │ ├── LICENSE │ └── README.md ├── demos ├── sample_scripts │ ├── exp4.planout │ ├── exp2.planout │ ├── exp3.planout │ ├── exp1.planout │ ├── exp4.json │ ├── exp2.json │ ├── exp3.json │ └── exp1.json ├── interpreter_experiment_examples.py ├── demo_namespaces.py ├── simple_experiment_examples.py ├── demo_experiments.py └── anchoring_demo.py ├── .gitignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── .gitmodules ├── compiler ├── readme.md └── planout.jison ├── PATENTS ├── contrib ├── postgres_logger.py └── pydata14_tutorial │ └── README.md ├── LICENSE ├── CONTRIBUTING.md ├── NEWS └── README.md /python/planout/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /python/planout/ops/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /python/planout/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /planout-editor/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | -------------------------------------------------------------------------------- /planout-editor/Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn planout-editor-kernel:app 2 | -------------------------------------------------------------------------------- /alpha/ruby/lib/plan_out/version.rb: -------------------------------------------------------------------------------- 1 | module PlanOut 2 | VERSION = '0.1.2' 3 | end 4 | -------------------------------------------------------------------------------- /alpha/ruby/test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/autorun' 2 | require_relative '../lib/plan_out' 3 | -------------------------------------------------------------------------------- /planout-editor/static/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamghill/planout/HEAD/planout-editor/static/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /planout-editor/static/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamghill/planout/HEAD/planout-editor/static/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /planout-editor/static/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamghill/planout/HEAD/planout-editor/static/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /demos/sample_scripts/exp4.planout: -------------------------------------------------------------------------------- 1 | prob_collapse = randomFloat(min=0.0, max=1.0, 2 | unit=sourceid); 3 | collapse = bernoulliTrial(p=prob_collapse, 4 | unit=[storyid, viewerid]); 5 | -------------------------------------------------------------------------------- /alpha/ruby/Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake/testtask' 2 | 3 | Rake::TestTask.new do |t| 4 | t.libs << "test" 5 | t.test_files = FileList['test/**/*_test.rb'] 6 | end 7 | 8 | task default: :test 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.DS_store 3 | *.log 4 | *.swp 5 | build/* 6 | python/build/* 7 | _site/* 8 | *node_modules/* 9 | *.tar.gz 10 | .ipynb_checkpoint* 11 | Gemfile.lock 12 | MANIFEST 13 | -------------------------------------------------------------------------------- /planout-editor/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==0.10.0 2 | Jinja2==2.7.3 3 | MarkupSafe==0.23 4 | PlanOut==0.5 5 | Werkzeug==0.9.6 6 | gunicorn==18.0 7 | itsdangerous==0.24 8 | wsgiref==0.1.2 9 | 10 | -------------------------------------------------------------------------------- /alpha/ruby/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in planout.gemspec 4 | gemspec 5 | 6 | group :test do 7 | gem 'rake' 8 | gem 'minitest', '~> 5.5' 9 | end 10 | -------------------------------------------------------------------------------- /alpha/ruby/lib/plan_out.rb: -------------------------------------------------------------------------------- 1 | require_relative 'plan_out/op_random' 2 | require_relative 'plan_out/assignment' 3 | require_relative 'plan_out/simple_experiment' 4 | require_relative 'plan_out/version' 5 | 6 | module PlanOut 7 | 8 | end 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 2.7 4 | - 3.4 5 | - 3.5 6 | - 3.6 7 | - nightly 8 | 9 | install: 10 | - pip install -e python 11 | 12 | script: 13 | - python -m unittest discover python/planout/test 14 | -------------------------------------------------------------------------------- /demos/sample_scripts/exp2.planout: -------------------------------------------------------------------------------- 1 | num_cues = randomInteger( 2 | min=1, max=min(length(liking_friends), 3), 3 | unit=[userid, pageid]); 4 | 5 | friends_shown = sample( 6 | choices=liking_friends, draws=num_cues, 7 | unit=[userid, pageid]); 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | Facebook has adopted a Code of Conduct that we expect project participants to adhere to. Please [read the full text](https://code.fb.com/codeofconduct) so that you can understand what actions will and will not be tolerated. -------------------------------------------------------------------------------- /demos/sample_scripts/exp3.planout: -------------------------------------------------------------------------------- 1 | has_banner = bernoulliTrial(p=0.97, unit=userid); 2 | cond_probs = [0.5, 0.95]; 3 | has_feed_stories = bernoulliTrial( 4 | p=cond_probs[has_banner], unit=userid); 5 | button_text = uniformChoice( 6 | choices=["I'm a voter", "I'm voting"], unit=userid); 7 | -------------------------------------------------------------------------------- /demos/sample_scripts/exp1.planout: -------------------------------------------------------------------------------- 1 | group_size = uniformChoice(choices=[1, 10], unit=userid); 2 | specific_goal = bernoulliTrial(p=0.8, unit=userid); 3 | 4 | if (specific_goal) { 5 | ratings_per_user_goal = uniformChoice( 6 | choices=[8, 16, 32, 64], unit=userid); 7 | ratings_goal = group_size * ratings_per_user_goal; 8 | } 9 | -------------------------------------------------------------------------------- /demos/sample_scripts/exp4.json: -------------------------------------------------------------------------------- 1 | {"op":"seq","seq":[{"op":"set","var":"prob_collapse","value":{"min":0,"max":1,"unit":{"op":"get","var":"sourceid"},"op":"randomFloat"}},{"op":"set","var":"collapse","value":{"p":{"op":"get","var":"prob_collapse"},"unit":{"op":"array","values":[{"op":"get","var":"storyid"},{"op":"get","var":"viewerid"}]},"op":"bernoulliTrial"}}]} -------------------------------------------------------------------------------- /python/docs/00-getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | 4 | ## Quick install 5 | You can install PlanOut using `pip`. Within a [`virtualenv`](http://www.virtualenv.org/en/latest/), install with 6 | 7 | ``` 8 | pip install planout 9 | ``` 10 | 11 | You can also install directly from source. Check out the git repository, create a virtualenv, activate it and run: 12 | 13 | ``` 14 | python setup.py install 15 | ``` 16 | -------------------------------------------------------------------------------- /alpha/ruby/lib/plan_out/simple_experiment.rb: -------------------------------------------------------------------------------- 1 | require_relative 'experiment' 2 | 3 | module PlanOut 4 | class SimpleExperiment < Experiment 5 | def configure_logger 6 | @logger = Logger.new(STDOUT) 7 | #@loger.level = Logger::WARN 8 | @logger.formatter = proc do |severity, datetime, progname, msg| 9 | "logged data: #{msg}\n" 10 | end 11 | end 12 | 13 | def log(data) 14 | @logger.info(JSON.dump(data)) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "java"] 2 | path = java 3 | url = https://github.com/Glassdoor/planout4j 4 | [submodule "alpha/js"] 5 | path = js 6 | url = https://github.com/HubSpot/PlanOut.js 7 | [submodule "php"] 8 | path = php 9 | url = https://github.com/vimeo/ABLincoln 10 | [submodule "alpha/julia"] 11 | path = alpha/julia 12 | url = https://github.com/rawls238/PlanOut.jl 13 | [submodule "alpha/golang"] 14 | path = alpha/golang 15 | url = https://github.com/biased-unit/planout-golang 16 | -------------------------------------------------------------------------------- /demos/sample_scripts/exp2.json: -------------------------------------------------------------------------------- 1 | {"op":"seq","seq":[{"op":"set","var":"num_cues","value":{"min":1,"max":{"values":[{"value":{"op":"get","var":"liking_friends"},"op":"length"},3],"op":"min"},"unit":{"op":"array","values":[{"op":"get","var":"userid"},{"op":"get","var":"pageid"}]},"op":"randomInteger"}},{"op":"set","var":"friends_shown","value":{"choices":{"op":"get","var":"liking_friends"},"draws":{"op":"get","var":"num_cues"},"unit":{"op":"array","values":[{"op":"get","var":"userid"},{"op":"get","var":"pageid"}]},"op":"sample"}}]} 2 | -------------------------------------------------------------------------------- /demos/sample_scripts/exp3.json: -------------------------------------------------------------------------------- 1 | {"op":"seq","seq":[{"op":"set","var":"has_banner","value":{"p":0.97,"unit":{"op":"get","var":"userid"},"op":"bernoulliTrial"}},{"op":"set","var":"cond_probs","value":{"op":"array","values":[0.5,0.95]}},{"op":"set","var":"has_feed_stories","value":{"p":{"op":"index","base":{"op":"get","var":"cond_probs"},"index":{"op":"get","var":"has_banner"}},"unit":{"op":"get","var":"userid"},"op":"bernoulliTrial"}},{"op":"set","var":"button_text","value":{"choices":{"op":"array","values":["I'm a voter","I'm voting"]},"unit":{"op":"get","var":"userid"},"op":"uniformChoice"}}]} 2 | -------------------------------------------------------------------------------- /planout-editor/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PlanOut Experiment Editor 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /alpha/ruby/lib/plan_out/operator.rb: -------------------------------------------------------------------------------- 1 | require 'digest/sha1' 2 | 3 | module PlanOut 4 | class Operator 5 | attr_accessor :args 6 | 7 | def initialize(parameters) 8 | @args = parameters 9 | end 10 | 11 | def execute(mapper) 12 | mapper.experiment_salt 13 | end 14 | end 15 | 16 | class OpSimple < Operator 17 | def execute(mapper) 18 | @mapper = mapper 19 | @parameters = {} 20 | 21 | @args.each do |key, value| 22 | @parameters[key] = mapper.evaluate(value) 23 | end 24 | 25 | simple_execute 26 | end 27 | 28 | def simple_execute 29 | -1 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /demos/sample_scripts/exp1.json: -------------------------------------------------------------------------------- 1 | {"op":"seq","seq":[{"op":"set","var":"group_size","value":{"choices":{"op":"array","values":[1,10]},"unit":{"op":"get","var":"userid"},"op":"uniformChoice"}},{"op":"set","var":"specific_goal","value":{"p":0.8,"unit":{"op":"get","var":"userid"},"op":"bernoulliTrial"}},{"op":"cond","cond":[{"if":{"op":"get","var":"specific_goal"},"then":{"op":"seq","seq":[{"op":"set","var":"ratings_per_user_goal","value":{"choices":{"op":"array","values":[8,16,32,64]},"unit":{"op":"get","var":"userid"},"op":"uniformChoice"}},{"op":"set","var":"ratings_goal","value":{"op":"product","values":[{"op":"get","var":"group_size"},{"op":"get","var":"ratings_per_user_goal"}]}}]}}]}]} -------------------------------------------------------------------------------- /demos/interpreter_experiment_examples.py: -------------------------------------------------------------------------------- 1 | from planout.experiment import SimpleInterpretedExperiment 2 | import json 3 | 4 | class Exp1(SimpleInterpretedExperiment): 5 | def loadScript(self): 6 | self.script = json.loads(open("sample_scripts/exp1.json").read()) 7 | 8 | class Exp2(SimpleInterpretedExperiment): 9 | def loadScript(self): 10 | self.script = json.loads(open("sample_scripts/exp2.json").read()) 11 | 12 | class Exp3(SimpleInterpretedExperiment): 13 | def loadScript(self): 14 | self.script = json.loads(open("sample_scripts/exp3.json").read()) 15 | 16 | class Exp4(SimpleInterpretedExperiment): 17 | def loadScript(self): 18 | self.script = json.loads(open("sample_scripts/exp4.json").read()) 19 | -------------------------------------------------------------------------------- /planout-editor/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Reactive PlanOut 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% block body %}{% endblock %} 17 | 18 | 19 | -------------------------------------------------------------------------------- /alpha/ruby/test/plan_out/operator_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | module PlanOut 4 | class OperatorTest < Minitest::Test 5 | def setup 6 | @a = Assignment.new('mtsalt') 7 | end 8 | 9 | def test_execute 10 | operator = Operator.new({ foo: 'bar' }) 11 | op_simple = OpSimple.new({ bar: 'qux' }) 12 | assert_equal('mtsalt', operator.execute(@a)) 13 | assert_equal(-1, op_simple.execute(@a)) 14 | end 15 | 16 | def test_weighted_choice 17 | weighted = WeightedChoice.new({ 18 | choices: ["c1", "c2", "c1"], 19 | weights: [20, 40, 60], 20 | unit: 42, 21 | salt:'x' 22 | }) 23 | assert_equal("c2", weighted.execute(@a)) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /python/docs/08-about-planout.md: -------------------------------------------------------------------------------- 1 | # About PlanOut 2 | PlanOut was originally developed by Eytan Bakshy and Dean Eckles at Facebook as a language for describing experimental designs. PlanOut is one of a few ways of setting up experiments at Facebook. The PlanOut interpreter runs on top of QuickExperiment, which is developed by Breno Roberto and Wesley May. 3 | 4 | This open source release accompanies "Designing and Deploying Online Field Experiments", which describes the PlanOut language and the semantics of our deployment and logging infrastructure. 5 | 6 | We hope that the software is useful for researchers and small businesses who want to run experiments out of the box, but have also tried to build things in a way that is easy to port and adapt to work with production systems. 7 | 8 | 9 | ### Acknowledgements 10 | -------------------------------------------------------------------------------- /compiler/readme.md: -------------------------------------------------------------------------------- 1 | # PlanOut Compiler 2 | 3 | `planout.js` is a script that parses a PlanOut experiment and outputs a serialized experiment in JSON format. 4 | 5 | ## Using the Compiler 6 | The script can be run from the command line via [node.js](http://nodejs.org/) as follows. 7 | ``` 8 | node planout.js ../demos/sample_scripts/exp1.planout 9 | ``` 10 | 11 | Alternatively, the script may be run from a web page. 12 | ``` 13 | 14 | 17 | ``` 18 | 19 | Here is a [demo](http://facebook.github.io/planout/demo/planout-compiler.html). 20 | 21 | ## Extending the PlanOut language 22 | The PlanOut grammar is specified in `planout.jison`. If you wish to extend PlanOut to support custom operators, you can modify the grammar file and generate a new compiler script using [Jison](http://zaach.github.io/jison/) library. 23 | -------------------------------------------------------------------------------- /planout-editor/static/planoutstyle.css: -------------------------------------------------------------------------------- 1 | pre.correct { 2 | width: 100%; 3 | } 4 | 5 | pre.error { 6 | width: 100%; 7 | background-color: #ffaaaa; 8 | } 9 | 10 | div.testerBox { 11 | width: 450px; 12 | } 13 | 14 | textarea.testcode { 15 | height: 200px; 16 | width: 100%; 17 | } 18 | 19 | div.boundingbox { 20 | width: 450px; 21 | } 22 | 23 | textarea { 24 | background-color: #2d2d2d; 25 | border-radius: 2px; 26 | border: 10px #2d2d2d solid; 27 | box-shadow: 0 2px 4px rgba(0,0,0,0.4); 28 | color: #cccccc; 29 | font-family:Consolas,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New, monospace; 30 | font-size: 14px; 31 | height: 300px; 32 | max-width: 100%; 33 | width: 100%; 34 | -moz-box-sizing: border-box; 35 | -webkit-box-sizing: border-box; 36 | box-sizing: border-box; 37 | } 38 | 39 | dt.fields { 40 | padding-bottom: 1px; 41 | padding-top: 12px; 42 | } 43 | -------------------------------------------------------------------------------- /alpha/ruby/lib/plan_out/assignment.rb: -------------------------------------------------------------------------------- 1 | module PlanOut 2 | class Assignment 3 | attr_accessor :experiment_salt, :data 4 | 5 | def initialize(experiment_salt) 6 | @experiment_salt = experiment_salt 7 | @data = {} 8 | end 9 | 10 | def evaluate(data) 11 | data 12 | end 13 | 14 | def get(var, default = nil) 15 | @data[var.to_sym] || default 16 | end 17 | 18 | # in python this would be defined as __setattr__ or __setitem__ 19 | # not sure how to do this in Ruby. 20 | def set(name, value) 21 | if value.is_a? Operator 22 | value.args[:salt] = name if !value.args.has_key?(:salt) 23 | @data[name.to_sym] = value.execute(self) 24 | else 25 | @data[name.to_sym] = value 26 | end 27 | end 28 | 29 | def [](x) 30 | get(x) 31 | end 32 | 33 | def []=(x,y) 34 | set(x,y) 35 | end 36 | 37 | def get_params 38 | @data 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /alpha/ruby/examples/plan_out/voting_experiment.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../lib/plan_out/simple_experiment' 2 | 3 | module PlanOut 4 | class VotingExperiment < SimpleExperiment 5 | def setup; end 6 | 7 | def assign(params, **inputs) 8 | userid = inputs[:userid] 9 | params[:button_color] = UniformChoice.new({ 10 | choices: ['ff0000', '00ff00'], 11 | unit: userid 12 | }) 13 | 14 | params[:button_text] = UniformChoice.new({ 15 | choices: ["I'm voting", "I'm a voter"], 16 | unit: userid, 17 | salt:'x' 18 | }) 19 | end 20 | end 21 | 22 | if __FILE__ == $0 23 | (14..16).each do |i| 24 | my_exp = VotingExperiment.new(userid:i) 25 | # toggling the above disables or re-enables auto-logging 26 | #my_exp.auto_exposure_log = false 27 | puts "\ngetting assignment for user #{i} note: first time triggers a log event" 28 | puts "button color is #{my_exp.get(:button_color)} and button text is #{my_exp.get(:button_text)}" 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /planout-editor/js/dispatcher/PlanOutEditorDispatcher.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2014, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the BSD-style license found in the 6 | * LICENSE file in the root directory of this source tree. An additional grant 7 | * of patent rights can be found in the PATENTS file in the same directory. 8 | * 9 | * PlanOutEditorDispatcher 10 | * 11 | * A singleton that operates as the central hub for application updates. 12 | */ 13 | 14 | var Dispatcher = require('flux').Dispatcher; 15 | var assign = require('object-assign'); 16 | 17 | var PlanOutEditorDispatcher = assign(new Dispatcher(), { 18 | 19 | /** 20 | * A bridge function between the views and the dispatcher, marking the action 21 | * as a view action. Another variant here could be handleServerAction. 22 | * @param {object} action The data coming from the view. 23 | */ 24 | handleViewAction: function(/*object*/ action) { 25 | this.dispatch({ 26 | source: 'VIEW_ACTION', 27 | action: action 28 | }); 29 | } 30 | 31 | }); 32 | 33 | module.exports = PlanOutEditorDispatcher; 34 | -------------------------------------------------------------------------------- /planout-editor/js/actions/PlanOutExperimentActions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2013-2014 Facebook, Inc. 3 | * 4 | * PlanOutExperimentActions 5 | */ 6 | 7 | var PlanOutEditorDispatcher = require('../dispatcher/PlanOutEditorDispatcher'); 8 | var PlanOutEditorConstants = require('../constants/PlanOutEditorConstants'); 9 | var ActionTypes = PlanOutEditorConstants.ActionTypes; 10 | 11 | 12 | var PlanOutExperimentActions = { 13 | loadScript: function(/*string*/ script) { 14 | PlanOutEditorDispatcher.handleViewAction({ 15 | actionType: ActionTypes.EDITOR_LOAD_SCRIPT, 16 | script: script 17 | }); 18 | }, 19 | 20 | compile: function(/*string*/ script) { 21 | PlanOutEditorDispatcher.handleViewAction({ 22 | actionType: ActionTypes.EDITOR_COMPILE_SCRIPT, 23 | script: script 24 | }); 25 | }, 26 | 27 | updateCompiledCode: function(/*string*/ script, /*string*/ status, /*object*/ json) { 28 | PlanOutEditorDispatcher.handleViewAction({ 29 | actionType: ActionTypes.EDITOR_UPDATE_COMPILED_CODE, 30 | script: script, 31 | status: status, 32 | json: json 33 | }); 34 | } 35 | }; 36 | 37 | module.exports = PlanOutExperimentActions; 38 | -------------------------------------------------------------------------------- /planout-editor/js/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2013-2014 Facebook, Inc. 3 | * 4 | * @jsx React.DOM 5 | */ 6 | 7 | var React = require('react'); 8 | 9 | var PlanOutTesterPanel = require('./components/PlanOutTesterPanel.react'); 10 | var PlanOutScriptPanel = require('./components/PlanOutScriptPanel.react'); 11 | var PlanOutEditorButtons = require('./components/PlanOutEditorButtons.react'); 12 | var PlanOutExperimentActions = require('./actions/PlanOutExperimentActions'); 13 | var DemoData = require('./utils/DemoData'); 14 | 15 | React.render( 16 |
17 |
18 |

PlanOut Experiment Editor

19 |
20 |
21 |
22 |
23 |
24 |
25 | 26 |
27 |

28 | Learn more about the PlanOut language 29 |

30 |
, 31 | document.getElementById('planouteditor') 32 | ); 33 | 34 | PlanOutExperimentActions.loadScript(DemoData.getDemoScript()); 35 | -------------------------------------------------------------------------------- /planout-editor/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "planout-editor", 3 | "version": "0.0.1", 4 | "description": "Reactive PlanOut interface", 5 | "main": "js/app.js", 6 | "dependencies": { 7 | "es6-promise": ">=0.1.1", 8 | "underscore": "~1.6.0", 9 | "codemirror": "~4.4.0", 10 | "flux": "^2.0.0", 11 | "keymirror": "~0.1.0", 12 | "object-assign": "^1.0.0", 13 | "react": "^0.12.0", 14 | "react-bootstrap": "^0.13.0" 15 | }, 16 | "devDependencies": { 17 | "browserify": ">=2.36.0", 18 | "envify": ">=1.2.0", 19 | "reactify": ">=0.4.0", 20 | "statics": ">=0.1.0", 21 | "uglify-js": ">=2.4.13", 22 | "watchify": ">=0.4.1", 23 | "jest-cli": "~0.1.5" 24 | }, 25 | "scripts": { 26 | "start": "STATIC_ROOT=./static watchify -o static/js/bundle.js -v -d .", 27 | "build": "STATIC_ROOT=./static NODE_ENV=production browserify . | uglifyjs -cm > static/js/bundle.min.js", 28 | "collect-static": "collect-static . ./static", 29 | "test": "jest" 30 | }, 31 | "author": "Eytan Bakshy", 32 | "license": "BSD", 33 | "browserify": { 34 | "transform": [ 35 | "reactify", 36 | "envify" 37 | ] 38 | }, 39 | "jest": { 40 | "rootDir": "./js" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /alpha/ruby/planout.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'plan_out/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "planout" 8 | spec.version = PlanOut::VERSION 9 | spec.authors = ["Eytan Bakshy", "Mohnish Thallavajhula"] 10 | spec.email = ["ebakshy@gmail.com", "i@mohni.sh"] 11 | spec.summary = %q{PlanOut is a framework and programming language for online field experimentation.} 12 | spec.description = %q{PlanOut is a framework and programming language for online field experimentation. PlanOut was created to make it easy to run and iterate on sophisticated experiments, while satisfying the constraints of deployed Internet services with many users.} 13 | spec.homepage = "https://facebook.github.io/planout" 14 | spec.license = "BSD" 15 | 16 | spec.files = `git ls-files -z`.split("\x0") 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_development_dependency "bundler", "~> 1.7" 22 | spec.add_development_dependency "minitest", "~> 5.5" 23 | spec.add_development_dependency "rake", "~> 10.0" 24 | end 25 | -------------------------------------------------------------------------------- /python/docs/04.1-extending-logging.md: -------------------------------------------------------------------------------- 1 | ## Extending logging functionality 2 | If you have already adopted some method for logging events, you may want to use that method to log experiment-related events, such as exposures and experiment-specific outcomes. You can do this by extending the `Experiment` abstract class and overriding the `log` method. 3 | 4 | ### Overriding the `log` method 5 | To log exposures using your existing logging system, just override the `log` method when extending the `Experiment` abstract class. For example, if you write to your logs with `MyLogger`, then you might create a class as follows. 6 | ```python 7 | 8 | def log(self, data): 9 | MyLogger.log(data) 10 | 11 | ``` 12 | 13 | ### Writing a log configuration method 14 | For some logging frameworks, you may want to have a way to setup your logger once per `Experiment` instance. In this case, you can implement a method to do this setup, which then gets called, as needed, by your `log` method. 15 | 16 | 17 | ### Adding caching 18 | By default, each instance of an Experiment class will only write to your logs once. But this can still result in a lot of writing when there are many instances created in a single request. So you may want to add some caching to prevent lots of unnecessary logging. Perhaps your logging system already handles this. Otherwise, you can add a key to a cache in the `log` method and check it before actually logging. 19 | -------------------------------------------------------------------------------- /alpha/ruby/test/plan_out/assignment_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | module PlanOut 4 | class AssignmentTest < Minitest::Test 5 | def setup 6 | @assignment = Assignment.new('mtsalt') 7 | end 8 | 9 | def test_salt 10 | assert_equal('mtsalt', @assignment.experiment_salt) 11 | end 12 | 13 | def test_evaluate 14 | assert_equal(1, @assignment.evaluate(1)) 15 | assert_equal(2, @assignment.evaluate(2)) 16 | end 17 | 18 | def test_set 19 | @assignment.set(:color, 'green') 20 | @assignment.set('platform', 'ios') 21 | @assignment.set('foo', UniformChoice.new({ unit: 1, choices: ['x', 'y'] })) 22 | assert_equal('green', @assignment.data[:color]) 23 | assert_equal('ios', @assignment.data[:platform]) 24 | assert_equal('y', @assignment.data[:foo]) 25 | end 26 | 27 | def test_get 28 | @assignment.set(:button_text, 'Click Me!') 29 | @assignment.set(:gender, 'f') 30 | assert_equal('Click Me!', @assignment.get('button_text')) 31 | assert_equal('f', @assignment.get('gender')) 32 | assert_equal(10, @assignment.get('missing_key', 10)) 33 | assert_nil(@assignment.get('missing_key')) 34 | end 35 | 36 | def test_get_params 37 | @assignment.set('foo', 'bar') 38 | @assignment.set(:baz, 'qux') 39 | assert_equal({ foo: 'bar', baz: 'qux' }, @assignment.get_params) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /alpha/ruby/test/plan_out/experiment_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | require_relative '../../examples/plan_out/voting_experiment' 3 | 4 | module PlanOut 5 | class ExperimentTest < Minitest::Test 6 | def setup 7 | @voting_experiment = VotingExperiment.new(userid: 14) 8 | @voting_experiment2 = VotingExperiment.new(userid: 15) 9 | @voting_experiment.auto_exposure_log = false 10 | @voting_experiment2.auto_exposure_log = false 11 | end 12 | 13 | def test_get_attributes 14 | assert_equal('ff0000', @voting_experiment.get(:button_color)) 15 | assert_equal(1, @voting_experiment.get(:missing_key, 1)) 16 | assert_equal('ff0000', @voting_experiment2.get(:button_color)) 17 | assert_equal("I'm voting", @voting_experiment.get(:button_text)) 18 | assert_equal("I'm voting", @voting_experiment2.get(:button_text)) 19 | end 20 | 21 | def test_get_params 22 | assert_equal({ button_color: 'ff0000', button_text: "I'm voting" }, @voting_experiment.get_params) 23 | assert_equal({ button_color: 'ff0000', button_text: "I'm voting" }, @voting_experiment2.get_params) 24 | end 25 | 26 | def test_as_blob 27 | result = @voting_experiment.as_blob 28 | assert_equal('PlanOut::VotingExperiment', result[:name]) 29 | assert_equal('PlanOut::VotingExperiment', result[:salt]) 30 | assert_equal({ userid: 14 }, result[:inputs]) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /PATENTS: -------------------------------------------------------------------------------- 1 | Additional Grant of Patent Rights 2 | 3 | "Software" means the PlanOut software distributed by Facebook, Inc. 4 | 5 | Facebook hereby grants you a perpetual, worldwide, royalty-free, non-exclusive, 6 | irrevocable (subject to the termination provision below) license under any 7 | rights in any patent claims owned by Facebook, to make, have made, use, sell, 8 | offer to sell, import, and otherwise transfer the Software. For avoidance of 9 | doubt, no license is granted under Facebook’s rights in any patent claims that 10 | are infringed by (i) modifications to the Software made by you or a third party, 11 | or (ii) the Software in combination with any software or other technology 12 | provided by you or a third party. 13 | 14 | The license granted hereunder will terminate, automatically and without notice, 15 | for anyone that makes any claim (including by filing any lawsuit, assertion or 16 | other action) alleging (a) direct, indirect, or contributory infringement or 17 | inducement to infringe any patent: (i) by Facebook or any of its subsidiaries or 18 | affiliates, whether or not such claim is related to the Software, (ii) by any 19 | party if such claim arises in whole or in part from any software, product or 20 | service of Facebook or any of its subsidiaries or affiliates, whether or not 21 | such claim is related to the Software, or (iii) by any party relating to the 22 | Software; or (b) that any right in any patent claim of Facebook is invalid or 23 | unenforceable. 24 | -------------------------------------------------------------------------------- /planout-editor/js/utils/DemoData.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2013-2014 Facebook, Inc. 3 | * 4 | * DemoData 5 | */ 6 | 7 | var PlanOutEditorConstants = require('../constants/PlanOutEditorConstants'); 8 | var TesterBoxTypes = PlanOutEditorConstants.TesterBoxTypes; 9 | 10 | module.exports = { 11 | getDemoTests: function() { 12 | // this will eventually come from the server 13 | var defaultTests = [ 14 | { 15 | "id": "playground", 16 | "inputs": {"userid": 42}, 17 | "type": TesterBoxTypes.PLAYGROUND 18 | }, 19 | { 20 | "id": "test3", 21 | "inputs":{"userid":5243}, 22 | "overrides": {}, 23 | "assertions": {"ratings_goal":640}, 24 | "type": TesterBoxTypes.TEST 25 | }, 26 | { 27 | "id": "test2", 28 | "inputs":{"userid":52433}, 29 | "overrides": {"group_size":10}, 30 | "assertions": {"ratings_goal": 200}, 31 | "type": TesterBoxTypes.TEST 32 | }, 33 | ]; 34 | return defaultTests; 35 | }, 36 | 37 | getDemoScript: function() { 38 | return [ 39 | "group_size = uniformChoice(choices=[1, 10], unit=userid);", 40 | "specific_goal = bernoulliTrial(p=0.8, unit=userid);", 41 | "if (specific_goal) {", 42 | " ratings_per_user_goal = uniformChoice(", 43 | " choices=[8, 16, 32, 64], unit=userid);", 44 | " ratings_goal = group_size * ratings_per_user_goal;", 45 | "}" 46 | ].join('\n'); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /contrib/postgres_logger.py: -------------------------------------------------------------------------------- 1 | from planout.experiment import SimpleExperiment 2 | 3 | import psycopg2 as pg 4 | from psycopg2.extras import Json as pJson 5 | 6 | class PostgresLoggedExperiment(SimpleExperiment): 7 | 8 | def configure_logger(self): 9 | """ Sets up a logger to postgres. 10 | 11 | 1. Modify the connection_parameters variable to be a dictionary of the 12 | parameters to create a connection to your postgres database. 13 | 2. Modify the table variable to be the table to which you plan on 14 | logging. 15 | """ 16 | 17 | connection_parameters = {'host': 'localhost', 18 | 'database': 'experiments'} 19 | table = 'experiments' 20 | 21 | self.conn = pg.connect(**connection_parameters) 22 | self.table = table 23 | 24 | def log(self, data): 25 | """ Log exposure. """ 26 | 27 | columns = ['inputs', 'name', 'checksum', 'params', 'time', 'salt', 28 | 'event'] 29 | 30 | names = ','.join(columns) 31 | placeholders = ','.join(['%s']*len(columns)) 32 | ins_statement = ('insert into {} ({}) values ({})' 33 | .format(self.table, names, placeholders)) 34 | 35 | row = [] 36 | for column in columns: 37 | value = data[column] 38 | row.append(pJson(value) if isinstance(value, dict) else value) 39 | 40 | with self.conn.cursor() as curr: 41 | curr.execute(ins_statement, row) 42 | 43 | self.conn.commit() 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD License 2 | 3 | For PlanOut software 4 | 5 | Copyright (c) 2014, Facebook, Inc. All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without modification, 8 | are permitted provided that the following conditions are met: 9 | 10 | * Redistributions of source code must retain the above copyright notice, this 11 | list of conditions and the following disclaimer. 12 | 13 | * Redistributions in binary form must reproduce the above copyright notice, 14 | this list of conditions and the following disclaimer in the documentation 15 | and/or other materials provided with the distribution. 16 | 17 | * Neither the name Facebook nor the names of its contributors may be used to 18 | endorse or promote products derived from this software without specific 19 | prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 22 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 23 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 25 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 26 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 27 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 28 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /alpha/ruby/LICENSE: -------------------------------------------------------------------------------- 1 | BSD License 2 | 3 | For PlanOut software 4 | 5 | Copyright (c) 2014, Facebook, Inc. All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without modification, 8 | are permitted provided that the following conditions are met: 9 | 10 | * Redistributions of source code must retain the above copyright notice, this 11 | list of conditions and the following disclaimer. 12 | 13 | * Redistributions in binary form must reproduce the above copyright notice, 14 | this list of conditions and the following disclaimer in the documentation 15 | and/or other materials provided with the distribution. 16 | 17 | * Neither the name Facebook nor the names of its contributors may be used to 18 | endorse or promote products derived from this software without specific 19 | prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 22 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 23 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 25 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 26 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 27 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 28 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /python/setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | setup( 4 | name='PlanOut', 5 | version='0.6.0', 6 | author='Facebook, Inc.', 7 | author_email='eytan@fb.com', 8 | packages=[ 9 | 'planout', 10 | 'planout.ops', 11 | 'planout.test' 12 | ], 13 | requires=[ 14 | 'six' 15 | ], 16 | url='http://pypi.python.org/pypi/PlanOut/', 17 | license='LICENSE', 18 | description='PlanOut is a framework for online field experimentation.', 19 | keywords=['experimentation', 'A/B testing'], 20 | classifiers=[ 21 | 'Development Status :: 5 - Production/Stable', 22 | 'Intended Audience :: Developers', 23 | 'License :: OSI Approved :: BSD License', 24 | 'Programming Language :: Python', 25 | 'Programming Language :: Python :: 2', 26 | 'Programming Language :: Python :: 2.7', 27 | 'Programming Language :: Python :: 3', 28 | 'Programming Language :: Python :: 3.4', 29 | 'Programming Language :: Python :: 3.5', 30 | 'Topic :: Software Development :: Libraries', 31 | 'Topic :: Software Development :: Testing', 32 | ], 33 | long_description="""PlanOut is a framework for online field experimentation. 34 | PlanOut makes it easy to design both simple A/B tests and more complex 35 | experiments, including multi-factorial designs and within-subjects designs. 36 | It also includes advanced features, including built-in logging, experiment 37 | management, and serialization of experiments via a domain-specific language. 38 | """, 39 | ) 40 | 41 | # long_description=open('README.md').read(), 42 | -------------------------------------------------------------------------------- /demos/demo_namespaces.py: -------------------------------------------------------------------------------- 1 | from planout.namespace import SimpleNamespace 2 | from planout.experiment import SimpleExperiment, DefaultExperiment 3 | from planout.ops.random import * 4 | 5 | 6 | class V1(SimpleExperiment): 7 | def assign(self, params, userid): 8 | params.banner_text = UniformChoice( 9 | choices=['Hello there!', 'Welcome!'], 10 | unit=userid) 11 | 12 | class V2(SimpleExperiment): 13 | def assign(self, params, userid): 14 | params.banner_text = WeightedChoice( 15 | choices=['Hello there!', 'Welcome!'], 16 | weights=[0.8, 0.2], 17 | unit=userid) 18 | 19 | class V3(SimpleExperiment): 20 | def assign(self, params, userid): 21 | params.banner_text = WeightedChoice( 22 | choices=['Nice to see you!', 'Welcome back!'], 23 | weights=[0.8, 0.2], 24 | unit=userid) 25 | 26 | 27 | class DefaultButtonExperiment(DefaultExperiment): 28 | def get_default_params(self): 29 | return {'banner_text': 'Generic greetings!'} 30 | 31 | class ButtonNamespace(SimpleNamespace): 32 | def setup(self): 33 | self.name = 'my_demo' 34 | self.primary_unit = 'userid' 35 | self.num_segments = 100 36 | self.default_experiment_class = DefaultButtonExperiment 37 | 38 | def setup_experiments(self): 39 | self.add_experiment('first version phase 1', V1, 10) 40 | self.add_experiment('first version phase 2', V1, 30) 41 | self.add_experiment('second version phase 1', V2, 40) 42 | self.remove_experiment('second version phase 1') 43 | self.add_experiment('third version phase 1', V3, 30) 44 | 45 | if __name__ == '__main__': 46 | for i in xrange(100): 47 | e = ButtonNamespace(userid=i) 48 | print 'user %s: %s' % (i, e.get('banner_text')) 49 | -------------------------------------------------------------------------------- /python/planout/test/test_assignment.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014, Facebook, Inc. 2 | # All rights reserved. 3 | # 4 | # This source code is licensed under the BSD-style license found in the 5 | # LICENSE file in the root directory of this source tree. An additional grant 6 | # of patent rights can be found in the PATENTS file in the same directory. 7 | 8 | import unittest 9 | 10 | from planout.assignment import Assignment 11 | from planout.ops.random import UniformChoice 12 | 13 | 14 | class AssignmentTest(unittest.TestCase): 15 | tester_unit = 4 16 | tester_salt = 'test_salt' 17 | 18 | def test_set_get_constant(self): 19 | a = Assignment(self.tester_salt) 20 | a.foo = 12 21 | self.assertEqual(a.foo, 12) 22 | 23 | def test_set_get_uniform(self): 24 | a = Assignment(self.tester_salt) 25 | a.foo = UniformChoice(choices=['a', 'b'], unit=self.tester_unit) 26 | a.bar = UniformChoice(choices=['a', 'b'], unit=self.tester_unit) 27 | a.baz = UniformChoice(choices=['a', 'b'], unit=self.tester_unit) 28 | self.assertEqual(a.foo, 'b') 29 | self.assertEqual(a.bar, 'a') 30 | self.assertEqual(a.baz, 'a') 31 | 32 | def test_overrides(self): 33 | a = Assignment(self.tester_salt) 34 | a.set_overrides({'x': 42, 'y': 43}) 35 | a.x = 5 36 | a.y = 6 37 | self.assertEqual(a.x, 42) 38 | self.assertEqual(a.y, 43) 39 | 40 | def test_custom_salt(self): 41 | a = Assignment(self.tester_salt) 42 | custom_salt = lambda x,y: '%s-%s' % (x,y) 43 | a.foo = UniformChoice(choices=list(range(8)), unit=self.tester_unit) 44 | self.assertEqual(a.foo, 7) 45 | 46 | if __name__ == '__main__': 47 | unittest.main() 48 | -------------------------------------------------------------------------------- /python/planout/test/test_namespace.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from planout.namespace import SimpleNamespace 4 | from planout.experiment import DefaultExperiment 5 | 6 | 7 | class VanillaExperiment(DefaultExperiment): 8 | def setup(self): 9 | self.name = 'test_name' 10 | 11 | def assign(self, params, i): 12 | params.foo = 'bar' 13 | 14 | 15 | class DefaultExperiment(DefaultExperiment): 16 | def get_default_params(self): 17 | return {'foo': 'default'} 18 | 19 | 20 | class NamespaceTest(unittest.TestCase): 21 | def test_namespace_remove_experiment(self): 22 | class TestVanillaNamespace(SimpleNamespace): 23 | def setup(self): 24 | self.name = 'test_namespace' 25 | self.primary_unit = 'i' 26 | self.num_segments = 100 27 | self.default_experiment_class = DefaultExperiment 28 | 29 | def setup_experiments(self): 30 | self.add_experiment('test_name', VanillaExperiment, 100) 31 | self.remove_experiment('test_name') 32 | 33 | assert TestVanillaNamespace(i=1).get('foo') == 'default' 34 | 35 | def test_namespace_add_experiment(self): 36 | class TestVanillaNamespace(SimpleNamespace): 37 | def setup(self): 38 | self.name = 'test_namespace' 39 | self.primary_unit = 'i' 40 | self.num_segments = 100 41 | self.default_experiment_class = DefaultExperiment 42 | 43 | def setup_experiments(self): 44 | self.add_experiment('test_name', VanillaExperiment, 100) 45 | 46 | assert TestVanillaNamespace(i=1).get('foo') == 'bar' 47 | 48 | 49 | if __name__ == '__main__': 50 | unittest.main() 51 | -------------------------------------------------------------------------------- /planout-editor/js/constants/PlanOutEditorConstants.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2013-2014 Facebook, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | * PlanOutEditorConstants 17 | */ 18 | 19 | var keyMirror = require('react/lib/keyMirror'); 20 | 21 | module.exports = { 22 | 23 | ActionTypes: keyMirror({ 24 | /** 25 | * Constants for actions 26 | * 27 | */ 28 | 29 | // Creating and destroying PlanOutTesterBoxes 30 | CREATE_TESTER: null, 31 | LOAD_SERIALIZED_TESTERS: null, 32 | INIT_TESTER_PANEL: null, 33 | TESTER_DESTROY: null, 34 | 35 | // Updating PlanOutTesterBox states 36 | TESTER_USER_UPDATE_TEST: null, 37 | TESTER_SERVER_UPDATE_TEST: null, 38 | TESTER_INVALID_TEST_FORM: null, 39 | TESTER_REFRESH_TEST: null, 40 | TESTER_REFRESH_ALL_TESTS: null, 41 | 42 | // Code Editor / compilation related actions 43 | EDITOR_LOAD_SCRIPT: null, 44 | EDITOR_COMPILE_SCRIPT: null, 45 | EDITOR_UPDATE_COMPILED_CODE: null 46 | }), 47 | 48 | TesterStatusCodes: keyMirror({ 49 | SUCCESS: null, 50 | FAILURE: null, 51 | INVALID_FORM: null, 52 | PENDING: null 53 | }), 54 | 55 | TesterBoxTypes: keyMirror({ 56 | TEST: null, 57 | PLAYGROUND: null 58 | }) 59 | }; 60 | -------------------------------------------------------------------------------- /python/docs/02-operators.md: -------------------------------------------------------------------------------- 1 | # Extending PlanOut 2 | 3 | Experiments 4 | - Logging 5 | - Assignment scheme 6 | - Salt 7 | - Uses a mapper 8 | 9 | Assignment schemes 10 | - Mappers are helpers for doing deterministic pseudorandom assignment 11 | 12 | Mappers: 13 | - One where you code in python 14 | - 15 | 16 | ## Core concepts 17 | An *Experiment* object takes inputs and maps it to parameter assignments. Experiment objects also handle logging and caching. 18 | 19 | *Mappers* are execution environments used to implement experiments. They execute *operators* which are functions that perform basic operations, including deterministic random assignment. 20 | 21 | 22 | ## Mappers 23 | A mapper translates inputs to parameter assignments. 24 | There are two main types of PlanOut mappers: `PlanOutKitMapper`, which is useful for many (ad hoc) experimentation needs, and `PlanOutInterpreterMapper`, which reads in serialized experiment definitions and is suitable for use in production environments when experiments are centrally managed via a Web interface. ``PlanOutInterpreterMapper`` 25 | 26 | ## Experiment class 27 | 28 | The experiment class implements core functionality associated with each experiment. In particular, every experiment has a: 29 | - name 30 | - experiment-level salt 31 | - an assignment scheme using a PlanOut mapper that translates inputs to parameter values. 32 | - logging, which by default maintains an "exposure log" of when inputs (e.g., userids) get mapped to parameter. This makes it easier to keep track of who was in your experiment, and restrict your analysis to the experiment. 33 | 34 | To define a new experiment, one subclasses the Experiment class. By default, the name of the experiment will be the name of the subclass, and the experiment-level salt will be the name of the experiment. 35 | -------------------------------------------------------------------------------- /planout-editor/static/js/mode/javascript/typescript.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | CodeMirror: TypeScript mode 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 24 | 25 |
26 |

TypeScript mode

27 | 28 | 29 |
51 | 52 | 59 | 60 |

This is a specialization of the JavaScript mode.

61 |
62 | -------------------------------------------------------------------------------- /planout-editor/static/js/mode/planout/typescript.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | CodeMirror: TypeScript mode 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 24 | 25 |
26 |

TypeScript mode

27 | 28 | 29 |
51 | 52 | 59 | 60 |

This is a specialization of the JavaScript mode.

61 |
62 | -------------------------------------------------------------------------------- /demos/simple_experiment_examples.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014, Facebook, Inc. 2 | # All rights reserved. 3 | # 4 | # This source code is licensed under the BSD-style license found in the 5 | # LICENSE file in the root directory of this source tree. An additional grant 6 | # of patent rights can be found in the PATENTS file in the same directory. 7 | 8 | from planout.experiment import SimpleExperiment 9 | from planout.ops.random import * 10 | 11 | class Exp1(SimpleExperiment): 12 | def assign(self, e, userid): 13 | e.group_size = UniformChoice(choices=[1, 10], unit=userid); 14 | e.specific_goal = BernoulliTrial(p=0.8, unit=userid); 15 | if e.specific_goal: 16 | e.ratings_per_user_goal = UniformChoice( 17 | choices=[8, 16, 32, 64], unit=userid) 18 | e.ratings_goal = e.group_size * e.ratings_per_user_goal 19 | return e 20 | 21 | class Exp2(SimpleExperiment): 22 | def assign(self, params, userid, pageid, liking_friends): 23 | params.num_cues = RandomInteger( 24 | min=1, 25 | max=min(len(liking_friends), 3), 26 | unit=[userid, pageid] 27 | ) 28 | params.friends_shown = Sample( 29 | choices=liking_friends, 30 | draws=params.num_cues, 31 | unit=[userid, pageid] 32 | ) 33 | 34 | class Exp3(SimpleExperiment): 35 | def assign(self, e, userid): 36 | e.has_banner = BernoulliTrial(p=0.97, unit=userid) 37 | cond_probs = [0.5, 0.95] 38 | e.has_feed_stories = BernoulliTrial(p=cond_probs[e.has_banner], unit=userid) 39 | e.button_text = UniformChoice( 40 | choices=["I'm a voter", "I'm voting"], unit=userid) 41 | 42 | 43 | class Exp4(SimpleExperiment): 44 | def assign(self, e, sourceid, storyid, viewerid): 45 | e.prob_collapse = RandomFloat(min=0.0, max=1.0, unit=sourceid) 46 | e.collapse = BernoulliTrial(p=e.prob_collapse, unit=[storyid, viewerid]) 47 | return e 48 | -------------------------------------------------------------------------------- /demos/demo_experiments.py: -------------------------------------------------------------------------------- 1 | import interpreter_experiment_examples as interpreter 2 | import simple_experiment_examples as simple_experiment 3 | 4 | def demo_experiment1(module): 5 | print 'using %s...' % module.__name__ 6 | exp1_runs = [module.Exp1(userid=i) for i in xrange(10)] 7 | print [(e.get('group_size'), e.get('ratings_goal')) for e in exp1_runs] 8 | 9 | def demo_experiment2(module): 10 | print 'using %s...' % module.__name__ 11 | # number of cues and selection of cues depends on userid and pageid 12 | for u in xrange(1,4): 13 | for p in xrange(1, 4): 14 | print module.Exp2(userid=u, pageid=p, liking_friends=['a','b','c','d']) 15 | 16 | def demo_experiment3(module): 17 | print 'using %s...' % module.__name__ 18 | for i in xrange(5): 19 | print module.Exp3(userid=i) 20 | 21 | def demo_experiment4(module): 22 | print 'using %s...' % module.__name__ 23 | for i in xrange(5): 24 | # probability of collapsing is deterministic on sourceid 25 | e = module.Exp4(sourceid=i, storyid=1, viewerid=1) 26 | # whether or not the story is collapsed depends on the sourceid 27 | exps = [module.Exp4(sourceid=i, storyid=1, viewerid=v) for v in xrange(10)] 28 | print e.get('prob_collapse'), [exp.get('collapse') for exp in exps] 29 | 30 | if __name__ == '__main__': 31 | # run each experiment implemented using SimpleExperiment (simple_experiment) 32 | # or using the interpreter 33 | print '\nDemoing experiment 1...' 34 | demo_experiment1(simple_experiment) 35 | demo_experiment1(interpreter) 36 | 37 | print '\nDemoing experiment 2...' 38 | demo_experiment2(simple_experiment) 39 | demo_experiment2(interpreter) 40 | 41 | print '\nDemoing experiment 3...' 42 | demo_experiment3(simple_experiment) 43 | demo_experiment3(interpreter) 44 | 45 | print '\nDemoing experiment 4...' 46 | demo_experiment4(simple_experiment) 47 | demo_experiment4(interpreter) 48 | -------------------------------------------------------------------------------- /planout-editor/README.md: -------------------------------------------------------------------------------- 1 | ## PlanOut Editor 2 | 3 | The PlanOut editor lets you interactively edit and test PlanOut code. It's built on Flux and React. 4 | 5 | ## Running 6 | 7 | The PlanOut editor executes PlanOut scripts and tests by sending data to a 8 | Python-based kernel which responds to AJAX requests. To start the kernel, 9 | run the following from your command line: 10 | 11 | `python planout-editor-kernel.py` 12 | 13 | Then, navigate to: 14 | 15 | [http://localhost:5000/](http://localhost:5000/). 16 | 17 | 18 | Note that the kernel requires that you have Flask and PlanOut already installed. 19 | If you don't have either package, you can install them via 20 | [pip](http://pip.readthedocs.org/en/latest/installing.html) by typing 21 | `pip install Flask` and `pip install planout`. 22 | 23 | 24 | ## Hacking the Editor 25 | If you want to make changes or contribute to the PlanOut editor, you must first 26 | have [npm](https://www.npmjs.org/) installed on your computer. 27 | 28 | You can then install all the package dependencies by going to the root directory 29 | and entering in the following into the command line. 30 | 31 | `npm install` 32 | 33 | If you don't already have watchify installed, you may have to type `npm install watchify` before entering `npm install`. 34 | 35 | Then, to build the project, run this command: 36 | 37 | `npm start` 38 | 39 | This will perform an initial build and start a watcher process that will 40 | continuously update the main application file, `bundle.js`, with any changes you make. This way, you can test your changes to the editor by simply saving your code and hitting refresh in your Web browser. 41 | 42 | This watcher is 43 | based on [Browserify](http://browserify.org/) and 44 | [Watchify](https://github.com/substack/watchify), and it transforms 45 | React's JSX syntax into standard JavaScript with 46 | [Reactify](https://github.com/andreypopp/reactify). 47 | -------------------------------------------------------------------------------- /python/docs/05-namespaces.md: -------------------------------------------------------------------------------- 1 | # Namespaces 2 | 3 | Namespaces are used to manage related experiments that manipulate the same parameter. These experiments might be run sequentially (over time) or in parallel. When experiments are conducted in parallel, namespaces can be used to keep experiments "exclusive" or "non-overlapping". 4 | 5 | 6 | Namespaces and models like [Google's "layers"](http://research.google.com/pubs/pub36500.html) and Facebook's "universes" solve the overlapping experiment problem by centering experiments around a primary unit, such as user IDs. Within a given namespace, each primary unit belongs to at most one experiment. 7 | 8 | 9 | ### How do namespaces work? 10 | Rather than requesting a parameter from an experiment, developers request a parameter from a namespace, which then handles identifying the experiment (if there is one) that that unit is part of. 11 | 12 | Under the hood, primary units are mapped to one of a large number of segments (e.g., 10,000). 13 | These segments are allocated to experiments. For any given unit, a namespace manager looks up that unit's segment. If the segment is allocated to an experiment, the input data is passed to the experiment, and random assignment occurs using the regular logic of the corresponding `Experiment` object. 14 | 15 | If the primary unit is not mapped to an experiment, or a parameter is requested that is not defined by the experiment, a default experiment or value may be used. 16 | This allows experimenters to configure default values for parameters on the fly in a way that does not interfere with currently running experiments. 17 | 18 | 19 | ### When do I need to use a namespace? 20 | Namespaces are useful whenever there is at least one variable in your code base that you would like to experiment with, either over time or simultaneously. 21 | 22 | As a starting point, PlanOut provides a basic implementation of namespaces with the `SimpleNamespace` class. 23 | -------------------------------------------------------------------------------- /contrib/pydata14_tutorial/README.md: -------------------------------------------------------------------------------- 1 | # PyData '14 PlanOut Tutorial 2 | Welcome to the PyData '14 Silicon Valley PlanOut tutorial! You'll find a collection of IPython notebooks for Eytan Bakshy's tutorial on PlanOut. 3 | 4 | If you want to follow along the live tutorial on your own computer (or are not at PyData!), you need to install some software and clone the PlanOut git repository. 5 | 6 | ## Requirements 7 | ### Software requirements 8 | The tutorial requires IPython, Pandas, and PlanOut. The former two come with Anaconda. PlanOut has only been tested on Mac OS X and Linux. 9 | 10 | * IPython ([installation instructions](http://ipython.org/install.html). We recommend Anaconda.) 11 | * PlanOut v0.2 or greater (first timers `sudo easy_install planout`, older timers `sudo easy_install --upgrade planout`) 12 | * Node.js - optional (installation link on the [node.js homepage](http://nodejs.org)) 13 | 14 | Note that you may need to re-install the `planout` package if you installed PlanOut before installing IPython. 15 | 16 | ### Downloading the tutorial files 17 | If you have git, you can checkout PlanOut by typing: 18 | 19 | ``` 20 | git clone https://github.com/facebook/planout.git 21 | ``` 22 | 23 | or you can [click here](https://github.com/facebook/planout/archive/master.zip) to download a zip archive. 24 | 25 | 26 | ## Loading up the tutorial notebooks 27 | Navigate to your checked out version of PlanOut and type: 28 | 29 | ``` 30 | cd contrib/pydata14_tutorial/ 31 | ipython notebook --pylab inline 32 | ``` 33 | 34 | Tutorial files include: 35 | * `0-getting-started.ipynb`: This teaches you the basics of how to implement experiments in pure Python. 36 | * `1-logging.ipynb`: How data is logged in PlanOut and examples of how to analyze PlanOut log data with Pandas. 37 | * `2-interpreter.ipynb`: How to generate serialized experiment definitions using (1) the PlanOut domain-specific language (2) automatically, e.g., via configuration files or GUIs. 38 | * `3-namespaces.ipynb`: How to manage simultaneous and follow-on experiments. 39 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to PlanOut 2 | We want to make contributing to this project as easy and transparent as 3 | possible. If you identify and bugs, have improvements to the code or documentation, 4 | we will be happy to review your code and integrate the changes into the main 5 | branch. 6 | 7 | If you are planning on porting PlanOut to other languages or platforms, 8 | please let us know! We are happy to review ports in detail, and either post 9 | them on our own repository, or link to your own repository as a submodule. 10 | 11 | ## Code of Conduct 12 | The code of conduct is described in [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md). 13 | 14 | ## Our Development Process 15 | The Python reference implementation mirrors the functionality of PlanOut and 16 | core parts of Facebook's experimentation stack, which is written in Hack (we 17 | hope to open source parts of this implementation in the near future). As 18 | we improve PlanOut internally, we will continue to maintain the Python 19 | implementation as to maintain feature parity. 20 | 21 | ## Pull Requests 22 | We actively welcome your pull requests. 23 | 1. Fork the repo and create your branch from `master`. 24 | 2. If you've added code that should be tested (most code should be!), add unit tests. 25 | 3. If you've changed APIs, update the documentation. 26 | 4. Ensure the test suite passes. 27 | 5. Make sure your code lints. 28 | 6. If you haven't already, complete the Contributor License Agreement ("CLA"). 29 | 30 | ## Contributor License Agreement ("CLA") 31 | In order to accept your pull request, we need you to submit a CLA. You only need 32 | to do this once to work on any of Facebook's open source projects. 33 | 34 | Complete your CLA here: 35 | 36 | ## Issues 37 | We use GitHub issues to track public bugs. Please ensure your description is 38 | clear and has sufficient instructions to be able to reproduce the issue. 39 | 40 | Facebook has a [bounty program](https://www.facebook.com/whitehat/) for the safe 41 | disclosure of security bugs. In those cases, please go through the process 42 | outlined on that page and do not file a public issue. 43 | 44 | ## Coding Style 45 | * 80 character line length 46 | * PEP-8 (if contributing Python code) 47 | 48 | 49 | ## License 50 | By contributing to PlanOut, you agree that your contributions will be licensed 51 | under its BSD license. 52 | -------------------------------------------------------------------------------- /python/docs/01-why-planout.md: -------------------------------------------------------------------------------- 1 | A/B tests and other randomized experiments are widely used as part of continually improving Web and mobile apps and services. PlanOut makes it easy to run both simple and complex experiments. 2 | 3 | ## Focus on parameters 4 | 5 | PlanOut is all about providing randomized values of parameters that control your service. Instead of using a constant, just use PlanOut to determine these parameters (e.g., text size or color, the presence of a new feature, the number of items in a list). Now you have an experiment. 6 | 7 | ## From simple to complex 8 | 9 | It is easy to implement an A/B test in PlanOut, or other simple experiments like those involving a factorial design. But is not much harder to implement more complex designs. Multiple types of units (e.g., users, pieces of content) can be randomly assigned to parameter values in the same experiment. Experiments can also involve directly randomizing other inputs, such as randomly selecting which three friends to display to a user. 10 | 11 | ## Automatic logging 12 | You will often want to keep track of which users (or other units) have been exposed to your experiment. This can make subsequent analysis more precise and prevent common errors in analysis. PlanOut calls your logging code whenever a parameter value is checked. 13 | 14 | 15 | ## Advanced features 16 | 17 | We created PlanOut to meet requirements from running experiments at Facebook, which gives rise to some of its more advanced features. 18 | 19 | ### Serialization 20 | Experiments can also be specified through JSON code. This can enable separate review processes for changes to the experiment, support multi-platform execution, and restrict the range of operations that should occur during experimental assignment (for reasons of, e.g., performance, correctness, static analysis). It also allows developers to implement their own tools to specify experiments without writing any code at all. 21 | 22 | ### Domain-specific language 23 | PlanOut experiments can be specified through the PlanOut language, which concisely describes an experiment using a set of primitive operations. PlanOut language code is compiled into the JSON serialization, which can be executed by the PlanOut interpreter as needed. 24 | 25 | ### Iterative experimentation 26 | The PlanOut library includes a basic namespace class (link) for managing multiple, iterative experiments that run concurrently. 27 | -------------------------------------------------------------------------------- /planout-editor/static/js/mode/javascript/json-ld.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | CodeMirror: JSON-LD mode 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 27 | 28 |
29 |

JSON-LD mode

30 | 31 | 32 |
61 | 62 | 70 | 71 |

This is a specialization of the JavaScript mode.

72 |
73 | -------------------------------------------------------------------------------- /planout-editor/static/js/mode/planout/json-ld.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | CodeMirror: JSON-LD mode 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 27 | 28 |
29 |

JSON-LD mode

30 | 31 | 32 |
61 | 62 | 70 | 71 |

This is a specialization of the JavaScript mode.

72 |
73 | -------------------------------------------------------------------------------- /planout-editor/js/components/PlanOutTesterBoxOutput.react.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2013-2014 Facebook, Inc. 3 | * 4 | * PlanOutTesterBoxOutput 5 | * 6 | * @jsx React.DOM 7 | */ 8 | 9 | var React = require('react'); 10 | var ReactPropTypes = React.PropTypes; 11 | 12 | 13 | var PlanOutTesterBoxOutput = React.createClass({ 14 | propTypes: { 15 | errors: ReactPropTypes.array, 16 | results: ReactPropTypes.object 17 | }, 18 | 19 | render: function() { 20 | var renderErrorMessage = function(error_message) { 21 | if ('expected' in error_message) { 22 | // expected param value assertion always contains expected_value 23 | return ( 24 | 25 | Expecting 26 | {error_message.param} to be 27 | {error_message.expected} but got 28 | {error_message.got} instead. 29 | 30 | ); 31 | } else { 32 | // otherwise we are missing an expected key 33 | return ( 34 | 35 | Expecting to see a parameter named {error_message.param}, 36 | but no such parameter could be found in the output. 37 | ); 38 | } 39 | }; 40 | 41 | var my_string = JSON.stringify(this.props.results, null, " "); 42 | var outputObject; 43 | if (this.props.errors && this.props.errors.length>0) { 44 | var firstError = this.props.errors[0]; 45 | if(firstError.error_code !== "assertion") { 46 | outputObject =
{firstError.message}
; 47 | } else { 48 | var n = 0; 49 | var rows = this.props.errors.map(function(row) { 50 | return ( 51 | 52 | {renderErrorMessage(row.message)} 53 | 54 | ) 55 | }.bind(this)); 56 | outputObject = ( 57 |
58 | 59 | 60 | {rows} 61 | 62 |
63 |
{my_string}
64 |
65 | ); 66 | } 67 | } else { 68 | outputObject =
{my_string}
; 69 | } 70 | return ( 71 |
72 | {outputObject} 73 |
74 | ); 75 | } 76 | }); 77 | 78 | module.exports = PlanOutTesterBoxOutput; 79 | -------------------------------------------------------------------------------- /alpha/ruby/lib/plan_out/op_random.rb: -------------------------------------------------------------------------------- 1 | require_relative 'operator' 2 | 3 | module PlanOut 4 | class OpRandom < OpSimple 5 | LONG_SCALE = Float(0xFFFFFFFFFFFFFFF) 6 | 7 | def get_unit(appended_unit = nil) 8 | unit = @parameters[:unit] 9 | unit = [unit] if !unit.is_a? Array 10 | unit += appended_unit if appended_unit != nil 11 | unit 12 | end 13 | 14 | def get_hash(appended_unit = nil) 15 | salt = @parameters[:salt] 16 | salty = "#{@mapper.experiment_salt}.#{salt}" 17 | unit_str = get_unit(appended_unit).join('.') 18 | x = "#{salty}.#{unit_str}" 19 | last_hex = (Digest::SHA1.hexdigest(x))[0..14] 20 | last_hex.to_i(16) 21 | end 22 | 23 | def get_uniform(min_val = 0.0, max_val = 1.0, appended_unit = nil) 24 | zero_to_one = self.get_hash(appended_unit)/LONG_SCALE 25 | min_val + (max_val-min_val) * zero_to_one 26 | end 27 | end 28 | 29 | class RandomFloat < OpRandom 30 | def simple_execute 31 | min_val = @parameters.fetch(:min, 0) 32 | max_val = @parameters.fetch(:max, 1) 33 | get_uniform(min_val, max_val) 34 | end 35 | end 36 | 37 | class RandomInteger < OpRandom 38 | def simple_execute 39 | min_val = @parameters.fetch(:min, 0) 40 | max_val = @parameters.fetch(:max, 1) 41 | min_val + get_hash() % (max_val - min_val + 1) 42 | end 43 | end 44 | 45 | class BernoulliTrial < OpRandom 46 | def simple_execute 47 | p = @parameters[:p] 48 | rand_val = get_uniform(0.0, 1.0) 49 | (rand_val <= p) ? 1 : 0 50 | end 51 | end 52 | 53 | class WeightedChoice < OpRandom 54 | def simple_execute 55 | choices = @parameters[:choices] 56 | weights = @parameters[:weights] 57 | 58 | return [] if choices.length() == 0 59 | 60 | cum_weights = Array.new(weights.length) 61 | cum_sum = 0.0 62 | 63 | weights.each_with_index do |weight, index| 64 | cum_sum += weight 65 | cum_weights[index] = cum_sum 66 | end 67 | 68 | stop_value = get_uniform(0.0, cum_sum) 69 | 70 | i = 0 71 | cum_weights.each_with_index do |cum_weight, index| 72 | return choices[index] if stop_value <= cum_weight 73 | end 74 | end 75 | end 76 | 77 | class UniformChoice < OpRandom 78 | def simple_execute 79 | choices = @parameters[:choices] 80 | return [] if choices.length() == 0 81 | rand_index = get_hash() % choices.length() 82 | choices[rand_index] 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /planout-editor/js/components/PlanOutTesterPanel.react.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014 Facebook, Inc. 3 | * 4 | * @providesModule PlanOutTesterPanel.react 5 | * @jsx React.DOM 6 | */ 7 | 8 | var React = require('react'); 9 | 10 | var PlanOutTesterActions = require('../actions/PlanOutTesterActions'); 11 | var PlanOutTesterBox = require('./PlanOutTesterBox.react'); 12 | var PlanOutTesterStore = require('../stores/PlanOutTesterStore'); 13 | 14 | //var Bootstrap = require('react-bootstrap'); 15 | //var Accordion = Bootstrap.Accordion; 16 | 17 | 18 | function getStateFromStores() { 19 | // will ventually also get data from PlanOutExperimentStore 20 | return { 21 | tests: PlanOutTesterStore.getAllTests() 22 | }; 23 | } 24 | 25 | var PlanOutTesterPanel = React.createClass({ 26 | 27 | getInitialState: function() { 28 | PlanOutTesterActions.init(); 29 | var state_data = getStateFromStores(); 30 | //state_data.expandedTest = Object.keys(state_data)[0]; 31 | return state_data; 32 | }, 33 | 34 | componentDidMount: function() { 35 | PlanOutTesterStore.addChangeListener(this._onChange); 36 | }, 37 | 38 | componentWillUnmount: function() { 39 | PlanOutTesterStore.removeChangeListener(this._onChange); 40 | }, 41 | 42 | _onChange: function() { 43 | this.setState(getStateFromStores()); 44 | }, 45 | 46 | render: function() { 47 | var boxes = this.state.tests.map(function(test) { 48 | return ( 49 | 61 | ); 62 | }, this); 63 | 64 | return ( 65 |
66 |
67 | {boxes} 68 |
69 | 74 | 79 |
80 | ); 81 | }, 82 | 83 | _onAddPlanOutTesterBoxEvent: function() { 84 | PlanOutTesterActions.create(); 85 | }, 86 | }); 87 | 88 | 89 | module.exports = PlanOutTesterPanel; 90 | -------------------------------------------------------------------------------- /python/planout/assignment.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014, Facebook, Inc. 2 | # All rights reserved. 3 | # 4 | # This source code is licensed under the BSD-style license found in the 5 | # LICENSE file in the root directory of this source tree. An additional grant 6 | # of patent rights can be found in the PATENTS file in the same directory. 7 | 8 | from .ops.random import * 9 | from .ops.base import PlanOutOp 10 | from collections import MutableMapping 11 | 12 | 13 | # The Assignment class is the main work horse that lets you to execute 14 | # random operators using the names of variables being assigned as salts. 15 | # It is a MutableMapping, which means it plays nice with things like Flask 16 | # template renders. 17 | class Assignment(MutableMapping): 18 | 19 | """ 20 | A mutable mapping that contains the result of an assign call. 21 | """ 22 | 23 | def __init__(self, experiment_salt, overrides={}): 24 | self.experiment_salt = experiment_salt 25 | self._overrides = overrides.copy() 26 | self._data = overrides.copy() 27 | self.salt_sep = '.' # separates unit from experiment/variable salt 28 | 29 | def evaluate(self, value): 30 | return value 31 | 32 | def get_overrides(self): 33 | return self._overrides 34 | 35 | def set_overrides(self, overrides): 36 | # maybe this should be a deep copy? 37 | self._overrides = overrides.copy() 38 | for var in self._overrides: 39 | self._data[var] = self._overrides[var] 40 | 41 | def __setitem__(self, name, value): 42 | if name in ('_data', '_overrides', 'salt_sep', 'experiment_salt'): 43 | self.__dict__[name] = value 44 | return 45 | 46 | if name in self._overrides: 47 | return 48 | 49 | if isinstance(value, PlanOutOpRandom): 50 | if 'salt' not in value.args: 51 | value.args['salt'] = name 52 | self._data[name] = value.execute(self) 53 | else: 54 | self._data[name] = value 55 | 56 | __setattr__ = __setitem__ 57 | 58 | def __getitem__(self, name): 59 | if name in ('_data', '_overrides', 'experiment_salt'): 60 | return self.__dict__[name] 61 | else: 62 | return self._data[name] 63 | 64 | __getattr__ = __getitem__ 65 | 66 | def __delitem__(self, name): 67 | del self._data[name] 68 | 69 | def __iter__(self): 70 | return iter(self._data) 71 | 72 | def __len__(self): 73 | return len(self._data) 74 | 75 | def __str__(self): 76 | return str(self._data) 77 | -------------------------------------------------------------------------------- /alpha/ruby/lib/plan_out/experiment.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | require 'json' 3 | 4 | module PlanOut 5 | class Experiment 6 | attr_accessor :auto_exposure_log 7 | 8 | def initialize(**inputs) 9 | @inputs = inputs 10 | @exposure_logged = false 11 | @_salt = nil 12 | @in_experiment = true 13 | @name = self.class.name 14 | @auto_exposure_log = true 15 | 16 | setup # sets name, salt, etc. 17 | 18 | @assignment = Assignment.new(salt) 19 | @assigned = false 20 | 21 | @logger = nil 22 | setup 23 | end 24 | 25 | def _assign 26 | configure_logger 27 | assign(@assignment, **@inputs) 28 | @in_experiment = @assignment.get(:in_experiment, @in_experiment) 29 | @assigned = true 30 | end 31 | 32 | def setup 33 | nil 34 | end 35 | 36 | def salt=(value) 37 | @_salt = value 38 | end 39 | 40 | def salt 41 | @_salt || @name 42 | end 43 | 44 | def auto_exposure_log=(value) 45 | @auto_exposure_log = value 46 | end 47 | 48 | def configure_logger 49 | nil 50 | end 51 | 52 | def requires_assignment 53 | _assign if !@assigned 54 | end 55 | 56 | def is_logged? 57 | @logged 58 | end 59 | 60 | def requires_exposure_logging 61 | log_exposure if @auto_exposure_log && @in_experiment && !@exposure_logged 62 | end 63 | 64 | def get_params 65 | requires_assignment 66 | requires_exposure_logging 67 | @assignment.get_params 68 | end 69 | 70 | def get(name, default = nil) 71 | requires_assignment 72 | requires_exposure_logging 73 | @assignment.get(name, default) 74 | end 75 | 76 | def assign(params, *inputs) 77 | # up to child class to implement 78 | nil 79 | end 80 | 81 | def log_event(event_type, extras = nil) 82 | if extras.nil? 83 | extra_payload = {event: event_type} 84 | else 85 | extra_payload = { 86 | event: event_type, 87 | extra_data: extras.clone 88 | } 89 | end 90 | 91 | log(as_blob(extra_payload)) 92 | end 93 | 94 | def log_exposure(extras = nil) 95 | @exposure_logged = true 96 | log_event(:exposure, extras) 97 | end 98 | 99 | def as_blob(extras = {}) 100 | d = { 101 | name: @name, 102 | time: Time.now.to_i, 103 | salt: salt, 104 | inputs: @inputs, 105 | params: @assignment.data 106 | } 107 | 108 | d.merge!(extras) 109 | end 110 | end 111 | 112 | end 113 | -------------------------------------------------------------------------------- /planout-editor/planout-editor-kernel.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, jsonify, render_template, request, url_for 2 | app = Flask(__name__) 3 | from planout.interpreter import Interpreter 4 | import traceback 5 | import json 6 | import sys 7 | 8 | def testPlanOutScript(script, inputs={}, overrides=None, assertions=None): 9 | payload = {} 10 | 11 | # make sure experiment runs with the given inputs 12 | i = Interpreter(script, 'demo_salt', inputs) 13 | if overrides: 14 | i.set_overrides(overrides) 15 | 16 | try: 17 | results = dict(i.get_params()) # executes experiment 18 | except Exception as err: 19 | #message = "Error running experiment: %s" % traceback.format_exc(0) 20 | message = "Error running experiment:\n%s" % err 21 | payload['errors'] = [{ 22 | "error_code": "runtime", 23 | "message": message 24 | }] 25 | return payload 26 | 27 | payload['results'] = results 28 | 29 | # validate if input contains validation code 30 | validation_errors = [] 31 | if assertions: 32 | for (key, value) in assertions.iteritems(): 33 | if key not in results: 34 | validation_errors.append({ 35 | "error_code": "assertion", 36 | "message": {"param": key} 37 | }) 38 | else: 39 | if results[key] != value: 40 | message = {'param': key, 'expected': value, 'got': results[key]} 41 | validation_errors.append({ 42 | "error_code": "assertion", 43 | "message": message 44 | }) 45 | if validation_errors: 46 | payload['errors'] = validation_errors 47 | 48 | return payload 49 | 50 | 51 | @app.route('/run_test') 52 | def run_test(): 53 | # not sure how to change everything to use POST requests 54 | raw_script = request.args.get('compiled_code', '') 55 | raw_inputs = request.args.get('inputs', '') 56 | raw_overrides = request.args.get('overrides', "{}") 57 | raw_assertions = request.args.get('assertions', "{}") 58 | id = request.args.get('id') 59 | 60 | script = json.loads(raw_script) if raw_script else {} 61 | try: 62 | inputs = json.loads(raw_inputs) 63 | overrides = json.loads(raw_overrides) if raw_overrides else None 64 | assertions = json.loads(raw_assertions) if raw_assertions else None 65 | except: 66 | return jsonify({ 67 | 'errors': [{ 68 | 'error_code': "INVALID_FORM", 69 | 'message': 'Invalid form input' 70 | }], 71 | 'id': id 72 | }) 73 | 74 | t = testPlanOutScript(script, inputs, overrides, assertions) 75 | t['id'] = id 76 | return jsonify(t) 77 | 78 | @app.route('/') 79 | def index(): 80 | return render_template('index.html') 81 | 82 | 83 | if __name__ == '__main__': 84 | app.run(debug=True) 85 | url_for('static', filename='planoutstyle.css') 86 | -------------------------------------------------------------------------------- /planout-editor/js/utils/PlanOutStaticAnalyzer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2004-present Facebook. All Rights Reserved. 3 | * 4 | * @providesModule PlanOutStaticAnalyzer 5 | * @typechecks 6 | */ 7 | 8 | var _ = require('underscore'); 9 | 10 | // code may be an object or array 11 | var _getVariables = function(code) /*object*/ { 12 | if (!code || Object.keys(code).length === 0) { 13 | return {'get_vars': [], 'set_vars': []}; 14 | } 15 | var get_vars = {}; 16 | var set_vars = {}; 17 | var child_vars = {}; 18 | 19 | var op = code.op; 20 | if (op === 'set') { 21 | set_vars[code['var']] = code.value; 22 | } else if (op === 'get') { 23 | get_vars[code['var']] = code.value; 24 | } 25 | for (var key in code) { 26 | if (typeof code[key] === 'object') { 27 | child_vars = _getVariables(code[key]); 28 | if (child_vars.get_vars) { 29 | for (var ck in child_vars.get_vars) { 30 | get_vars[ck] = child_vars.get_vars[ck]; 31 | } 32 | } 33 | if (child_vars.set_vars) { 34 | for (var ck in child_vars.set_vars) { 35 | set_vars[ck] = child_vars.set_vars[ck]; 36 | } 37 | } 38 | } 39 | } 40 | 41 | return {'get_vars': get_vars, 'set_vars': set_vars}; 42 | }; 43 | 44 | function _getParams(/*object*/ code) /*array*/ { 45 | var vars = _getVariables(code); 46 | return Object.keys(vars.set_vars); 47 | } 48 | 49 | function _getParamValue(/*object*/ code, /*string*/ var_name) { 50 | return _getVariables(code).set_vars[var_name]; 51 | } 52 | 53 | var PlanOutStaticAnalyzer = { 54 | inputVariables: function(/*object*/ code) /*array*/ { 55 | var vars = _getVariables(code); 56 | var input_vars = []; 57 | for (var v in vars.get_vars) { 58 | if (!(v in vars.set_vars)) { 59 | input_vars.push(v); 60 | } 61 | } 62 | return input_vars; 63 | }, 64 | 65 | params: _getParams, 66 | 67 | getLoggedParams: function(/*object*/ code) /*array*/ { 68 | var loggedParams = _getParamValue(code, 'log'); 69 | // interpret array operators as literal arrays 70 | if (loggedParams instanceof Object && loggedParams.op == 'array') { 71 | loggedParams = loggedParams.values; 72 | } 73 | if (loggedParams) { 74 | if (loggedParams instanceof Array) { 75 | return _.intersection( 76 | _getParams(code), 77 | loggedParams.filter(function(x) {return typeof x === 'string';}) 78 | ); 79 | } else { 80 | console.log( 81 | 'Error: PlanOut variable named log must be an array of strings, ' + 82 | 'but got ', loggedParams, ' instead.' 83 | ); 84 | return []; 85 | } 86 | } else { 87 | return _getParams(code); 88 | } 89 | } 90 | }; 91 | 92 | module.exports = PlanOutStaticAnalyzer; 93 | -------------------------------------------------------------------------------- /planout-editor/js/actions/PlanOutTesterActions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2013-2014 Facebook, Inc. 3 | * 4 | * PlanOutTesterActions 5 | */ 6 | 7 | var PlanOutEditorDispatcher = require('../dispatcher/PlanOutEditorDispatcher'); 8 | 9 | var PlanOutEditorConstants = require('../constants/PlanOutEditorConstants'); 10 | var ActionTypes = PlanOutEditorConstants.ActionTypes; 11 | 12 | 13 | var PlanOutTesterActions = { 14 | init: function() { 15 | PlanOutEditorDispatcher.handleViewAction({ 16 | actionType: ActionTypes.INIT_TESTER_PANEL 17 | }); 18 | }, 19 | 20 | create: function() { 21 | PlanOutEditorDispatcher.handleViewAction({ 22 | actionType: ActionTypes.TESTER_CREATE 23 | }); 24 | }, 25 | 26 | /** 27 | * @param {objects} tests to initialize PlanOutTesterPanel 28 | */ 29 | loadTests: function(/*object*/ tests) { 30 | PlanOutEditorDispatcher.handleViewAction({ 31 | actionType: ActionTypes.LOAD_SERIALIZED_TESTERS, 32 | tests: tests 33 | }); 34 | }, 35 | 36 | /** 37 | * Re-runs all tests 38 | */ 39 | refreshAllTests: function() { 40 | PlanOutEditorDispatcher.handleViewAction({ 41 | actionType: ActionTypes.TESTER_REFRESH_ALL_TESTS 42 | }); 43 | }, 44 | 45 | /** 46 | * @param {string} id The ID of the tester item 47 | * @param {string} fields_to_update named valid JSON representing each field 48 | */ 49 | updateTester: function( /*string*/ id, /*object*/ fields_to_update) { 50 | PlanOutEditorDispatcher.handleViewAction({ 51 | actionType: ActionTypes.TESTER_USER_UPDATE_TEST, 52 | id: id, 53 | fieldsToUpdate: fields_to_update 54 | }); 55 | }, 56 | 57 | // Eventually this might be handled by a UI component that has users enter the 58 | // data as structured data using some nice form, so we won't need to handle 59 | // this exception. 60 | // This is separate from updateTesterOutput because we don't want to keep the 61 | // output and errors fixed while the user is editing the JSON field 62 | updateTesterWithInvalidForm: function(/*string*/ id, /*string*/ status) { 63 | PlanOutEditorDispatcher.handleViewAction({ 64 | actionType: ActionTypes.TESTER_INVALID_TEST_FORM, 65 | id: id, 66 | status: status 67 | }); 68 | }, 69 | 70 | updateTesterOutput: function(/*string*/ id, /*array*/ errors, /*object*/ results) { 71 | PlanOutEditorDispatcher.handleViewAction({ 72 | actionType: ActionTypes.TESTER_SERVER_UPDATE_TEST, 73 | id: id, 74 | errors: errors, 75 | results: results 76 | }); 77 | }, 78 | 79 | /** 80 | * @param {string} id 81 | */ 82 | destroy: function(/*string*/ id) { 83 | PlanOutEditorDispatcher.handleViewAction({ 84 | actionType: ActionTypes.TESTER_DESTROY, 85 | id: id 86 | }); 87 | } 88 | }; 89 | 90 | module.exports = PlanOutTesterActions; 91 | -------------------------------------------------------------------------------- /planout-editor/js/components/PlanOutTesterBoxFormInput.react.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014 Facebook, Inc. 3 | * 4 | * @providesModule PlanOutTesterBoxFormInput.react 5 | * @jsx React.DOM 6 | */ 7 | 8 | 9 | var React = require('react'); 10 | var ReactPropTypes = React.PropTypes; 11 | var Input = require('react-bootstrap/Input'); 12 | 13 | var PlanOutTesterActions = require('../actions/PlanOutTesterActions'); 14 | 15 | var PlanOutTesterBoxFormInput = React.createClass({ 16 | propTypes: { 17 | json: React.PropTypes.object, 18 | fieldName: React.PropTypes.string.isRequired, 19 | id: React.PropTypes.string.isRequired, 20 | label: React.PropTypes.string.isRequired 21 | }, 22 | 23 | getInitialState: function() { 24 | // value can take on any value users types into the textarea 25 | // json only gets updated when this value is valid JSON 26 | return { 27 | isValid: true, 28 | inFocusValue: JSON.stringify(this.props.json || {}, null, ' '), 29 | inFocus: false 30 | }; 31 | }, 32 | 33 | _onChange: function(event) { 34 | var value = event.target.value; 35 | try { 36 | var payload = {}; 37 | payload[this.props.fieldName] = JSON.parse(value); 38 | this.setState({isValid: true, inFocusValue: value}); 39 | PlanOutTesterActions.updateTester( 40 | this.props.id, 41 | payload 42 | ); 43 | } catch (e) { 44 | this.setState({isValid: false, inFocusValue: value}); 45 | } 46 | }, 47 | 48 | getDisplayedValue: function() { 49 | if (this.state.inFocus) { 50 | return this.state.inFocusValue; 51 | } 52 | if (!this.state.isValid) { 53 | return this.state.inFocusValue; 54 | } 55 | return JSON.stringify(this.props.json, null, " "); 56 | }, 57 | 58 | _onBlur: function () { 59 | var payload = {inFocus: false}; 60 | if (this.state.isValid) { 61 | payload.inFocusValue = JSON.stringify(this.props.json, null, " "); 62 | } 63 | this.setState(payload); 64 | }, 65 | 66 | _onFocus: function () { 67 | var payload = {inFocus: true}; 68 | if (this.state.isValid) { 69 | payload.inFocusValue = JSON.stringify(this.props.json, null, " "); 70 | } 71 | this.setState(payload); 72 | }, 73 | 74 | jsonHeight: function() { 75 | return 20 + 20 * ((this.getDisplayedValue() || '').split('\n').length); 76 | }, 77 | 78 | render: function() { 79 | return ( 80 | 89 | ); 90 | } 91 | }); 92 | 93 | module.exports = PlanOutTesterBoxFormInput; 94 | -------------------------------------------------------------------------------- /alpha/ruby/README.md: -------------------------------------------------------------------------------- 1 | # PlanOut 2 | [![Gem Version](https://badge.fury.io/rb/planout.svg)](http://badge.fury.io/rb/planout) 3 | [![Build Status](https://travis-ci.org/facebook/planout.svg)](https://travis-ci.org/facebook/planout) 4 | 5 | ## Overview 6 | This is a rough implementation of the Experiment / logging infrastructure for running PlanOut experiments, with all the random assignment operators available in Python. This port is nearly a line-by-line port, and produces assignments that are completely consistent with those produced by the Python reference implementation. 7 | 8 | ## Installation 9 | Add this line to your application's Gemfile: 10 | 11 | ```ruby 12 | gem 'planout' 13 | ``` 14 | 15 | And then execute: 16 | ```bash 17 | $ bundle 18 | ``` 19 | 20 | Or install it yourself as: 21 | ```bash 22 | $ gem install planout 23 | ``` 24 | 25 | ## How it works 26 | 27 | This defines a simple experiment that randomly assigns three variables, foo, bar, and baz. 28 | `foo` and `baz` use `userid` as input, while `bar` uses a pair, namely `userid` combined with the value of `foo` from the prior step. 29 | 30 | ```ruby 31 | module PlanOut 32 | class VotingExperiment < SimpleExperiment 33 | # Experiment#assign takes params and an input array 34 | def assign(params, **inputs) 35 | userid = inputs[:userid] 36 | 37 | params[:button_color] = UniformChoice.new({ 38 | choices: ['ff0000', '#00ff00'], 39 | unit: userid 40 | }) 41 | 42 | params[:button_text] = UniformChoice.new({ 43 | choices: ["I'm voting", "I'm a voter"], 44 | unit: userid, 45 | salt:'x' 46 | }) 47 | end 48 | end 49 | end 50 | ``` 51 | 52 | Then, we can examine the assignments produced for a few input userids. Note that since exposure logging is enabled by default, all of the experiments' inputs, configuration information, timestamp, and parameter assignments are pooped out via the Logger class. 53 | 54 | ```ruby 55 | my_exp = PlanOut::VotingExperiment.new(userid: 14) 56 | my_button_color = my_exp.get(:button_color) 57 | button_text = my_exp.get(:button_text) 58 | puts "button color is #{my_button_color} and button text is #{button_text}." 59 | ``` 60 | 61 | The output of the Ruby script looks something like this: 62 | 63 | ```ruby 64 | logged data: {"name":"PlanOut::VotingExperiment","time":1404944726,"salt":"PlanOut::VotingExperiment","inputs":{"userid":14},"params":{"button_color":"ff0000","button_text":"I'm a voter"},"event":"exposure"} 65 | 66 | button color is ff0000 and button text is I'm a voter. 67 | ``` 68 | ## Examples 69 | 70 | For examples please refer to the `examples` directory. 71 | 72 | ## Running the tests 73 | Make sure you're in the ruby implementation directory of PlanOut and run 74 | 75 | `rake` or `rake test` 76 | 77 | to run the entire test suite. 78 | 79 | If you wish to run a specific test, run 80 | 81 | `rake test TEST=test/testname.rb` or even better `ruby test/testname.rb` 82 | -------------------------------------------------------------------------------- /python/planout/test/test_interpreter.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014, Facebook, Inc. 2 | # All rights reserved. 3 | # 4 | # This source code is licensed under the BSD-style license found in the 5 | # LICENSE file in the root directory of this source tree. An additional grant 6 | # of patent rights can be found in the PATENTS file in the same directory. 7 | 8 | import json 9 | import unittest 10 | 11 | from planout.interpreter import Interpreter 12 | 13 | 14 | class InterpreterTest(unittest.TestCase): 15 | compiled = json.loads(""" 16 | {"op":"seq","seq":[{"op":"set","var":"group_size","value":{"choices":{"op":"array","values":[1,10]},"unit":{"op":"get","var":"userid"},"op":"uniformChoice"}},{"op":"set","var":"specific_goal","value":{"p":0.8,"unit":{"op":"get","var":"userid"},"op":"bernoulliTrial"}},{"op":"cond","cond":[{"if":{"op":"get","var":"specific_goal"},"then":{"op":"seq","seq":[{"op":"set","var":"ratings_per_user_goal","value":{"choices":{"op":"array","values":[8,16,32,64]},"unit":{"op":"get","var":"userid"},"op":"uniformChoice"}},{"op":"set","var":"ratings_goal","value":{"op":"product","values":[{"op":"get","var":"group_size"},{"op":"get","var":"ratings_per_user_goal"}]}}]}}]}]} 17 | """) 18 | interpreter_salt = 'foo' 19 | 20 | def test_interpreter(self): 21 | proc = Interpreter( 22 | self.compiled, self.interpreter_salt, {'userid': 123454}) 23 | params = proc.get_params() 24 | self.assertEqual(proc.get_params().get('specific_goal'), 1) 25 | self.assertEqual(proc.get_params().get('ratings_goal'), 320) 26 | 27 | def test_interpreter_overrides(self): 28 | # test overriding a parameter that gets set by the experiment 29 | proc = Interpreter( 30 | self.compiled, self.interpreter_salt, {'userid': 123454}) 31 | proc.set_overrides({'specific_goal': 0}) 32 | self.assertEqual(proc.get_params().get('specific_goal'), 0) 33 | self.assertEqual(proc.get_params().get('ratings_goal'), None) 34 | 35 | # test to make sure input data can also be overridden 36 | proc = Interpreter( 37 | self.compiled, self.interpreter_salt, {'userid': 123453}) 38 | proc.set_overrides({'userid': 123454}) 39 | self.assertEqual(proc.get_params().get('specific_goal'), 1) 40 | 41 | def test_register_ops(self): 42 | from planout.ops.base import PlanOutOpCommutative 43 | class CustomOp(PlanOutOpCommutative): 44 | def commutativeExecute(self, values): 45 | return sum(values) 46 | 47 | custom_op_script = {"op":"seq","seq":[{"op":"set","var":"x","value":{"values":[2,4],"op":"customOp"}}]} 48 | proc = Interpreter( 49 | custom_op_script, self.interpreter_salt, {'userid': 123454}) 50 | 51 | proc.register_operators({'customOp': CustomOp}) 52 | self.assertEqual(proc.get_params().get('x'), 6) 53 | 54 | 55 | 56 | if __name__ == '__main__': 57 | unittest.main() 58 | -------------------------------------------------------------------------------- /planout-editor/js/stores/PlanOutExperimentStore.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2013-2014 Facebook, Inc. 3 | * 4 | * PlanOutExperimentStore 5 | */ 6 | 7 | var _ = require('underscore'); 8 | var EventEmitter = require('events').EventEmitter; 9 | var assign = require('object-assign'); 10 | 11 | var PlanOutAsyncRequests = require('../utils/PlanOutAsyncRequests'); 12 | var PlanOutEditorConstants = require('../constants/PlanOutEditorConstants'); 13 | var PlanOutEditorDispatcher = require('../dispatcher/PlanOutEditorDispatcher'); 14 | var ActionTypes = PlanOutEditorConstants.ActionTypes; 15 | var PlanOutStaticAnalyzer = require('../utils/PlanOutStaticAnalyzer'); 16 | 17 | var CHANGE_EVENT = 'change'; 18 | 19 | var _script = ''; 20 | var _json = {}; 21 | var _compilation_status = 'success'; 22 | 23 | 24 | var PlanOutExperimentStore = assign({}, EventEmitter.prototype, { 25 | getScript: function() /*string*/ { 26 | return _script; 27 | }, 28 | 29 | getJSON: function() /*object*/ { 30 | return _json; 31 | }, 32 | 33 | getInputVariables: function() /*array*/ { 34 | return PlanOutStaticAnalyzer.inputVariables(_json); 35 | }, 36 | 37 | getParams: function() /*array*/ { 38 | return PlanOutStaticAnalyzer.params(_json); 39 | }, 40 | 41 | getCompilerMessage: function() /*string*/ { 42 | return _compilation_status === 'success' ? 43 | 'Compilation successful!' : _compilation_status; 44 | }, 45 | 46 | doesCompile: function() /*bool*/ { 47 | return _compilation_status === 'success'; 48 | }, 49 | 50 | emitChange: function() { 51 | this.emit(CHANGE_EVENT); 52 | }, 53 | 54 | /** 55 | * @param {function} callback 56 | */ 57 | addChangeListener: function(/*function*/ callback) { 58 | this.addListener(CHANGE_EVENT, callback); 59 | }, 60 | 61 | /** 62 | * @param {function} callback 63 | */ 64 | removeChangeListener: function(/*function*/ callback) { 65 | this.removeListener(CHANGE_EVENT, callback); 66 | }, 67 | 68 | dispatchToken: PlanOutEditorDispatcher.register( 69 | function(/*object*/ payload) 70 | { 71 | var action = payload.action; 72 | 73 | switch(action.actionType) { 74 | case ActionTypes.EDITOR_COMPILE_SCRIPT: 75 | _script = action.script; 76 | if (action.script.trim() === "") { 77 | _json = {}; 78 | _compilation_status = 'success'; 79 | break; 80 | } 81 | PlanOutAsyncRequests.compileScript(action.script); 82 | break; 83 | 84 | case ActionTypes.EDITOR_LOAD_SCRIPT: 85 | _script = action.script; 86 | PlanOutAsyncRequests.compileScript(action.script); 87 | break; 88 | 89 | case ActionTypes.EDITOR_UPDATE_COMPILED_CODE: 90 | _json = action.json; 91 | _compilation_status = action.status; 92 | break; 93 | 94 | default: 95 | // no change needed to emit 96 | return true; 97 | } 98 | 99 | PlanOutExperimentStore.emitChange(); 100 | return true; 101 | }) 102 | }); 103 | 104 | module.exports = PlanOutExperimentStore; 105 | -------------------------------------------------------------------------------- /planout-editor/js/components/PlanOutEditorButtons.react.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2013-2014 Facebook, Inc. 3 | * 4 | * @providesModule PlanOutEditorButtons.react 5 | * @jsx React.DOM 6 | */ 7 | 8 | var _ = require('underscore'); 9 | var React = require('react/addons'); 10 | var ReactPropTypes = React.PropTypes; 11 | var cx = React.addons.classSet; 12 | 13 | var PlanOutAsyncRequests = require('../utils/PlanOutAsyncRequests'); 14 | var PlanOutExperimentActions = require('../actions/PlanOutExperimentActions'); 15 | var PlanOutExperimentStore = require('../stores/PlanOutExperimentStore'); 16 | var PlanOutTesterActions = require('../actions/PlanOutTesterActions'); 17 | var PlanOutTesterStore = require('../stores/PlanOutTesterStore'); 18 | 19 | var DemoData = require('../utils/DemoData'); 20 | 21 | 22 | function getStateFromStores() /*object*/ { 23 | return { 24 | doesCompile: PlanOutExperimentStore.doesCompile(), 25 | json: PlanOutExperimentStore.getJSON(), 26 | passesTests: PlanOutTesterStore.areAllPassing(), 27 | script: PlanOutExperimentStore.getScript(), 28 | tests: PlanOutTesterStore.getAllTests() 29 | }; 30 | } 31 | 32 | var PlanOutEditorButtons = React.createClass({ 33 | getInitialState: function() /*object*/ { 34 | return getStateFromStores(); 35 | }, 36 | 37 | componentDidMount: function() { 38 | PlanOutTesterStore.addChangeListener(this._onChange); 39 | PlanOutExperimentStore.addChangeListener(this._onChange); 40 | }, 41 | 42 | componentWillUnmount: function() { 43 | PlanOutTesterStore.removeChangeListener(this._onChange); 44 | PlanOutExperimentStore.removeChangeListener(this._onChange); 45 | }, 46 | 47 | _onChange: function() { 48 | this.setState(getStateFromStores()); 49 | }, 50 | 51 | render: function() { 52 | var cx = React.addons.classSet; 53 | var buttonClass = cx({ 54 | 'btn': true, 55 | 'btn-default': true, 56 | 'disabled': !(this.state.doesCompile && this.state.passesTests) 57 | }); 58 | return ( 59 |
60 | 65 |    66 | 70 |    71 | 75 |
76 | ); 77 | }, 78 | 79 | _loadSampleData: function() { 80 | PlanOutExperimentActions.loadScript(DemoData.getDemoScript()); 81 | PlanOutTesterActions.loadTests(DemoData.getDemoTests()); 82 | PlanOutTesterActions.refreshAllTests(); 83 | }, 84 | 85 | _saveJSON: function() { 86 | PlanOutAsyncRequests.saveState(this.state.json, "experiment_code.json"); 87 | }, 88 | 89 | _saveAll: function() { 90 | var all = { 91 | script: this.state.script, 92 | json: this.state.json, 93 | tests: PlanOutTesterStore.getSerializedTests() 94 | }; 95 | PlanOutAsyncRequests.saveState(all, "experiment_all.json"); 96 | }, 97 | }); 98 | 99 | module.exports = PlanOutEditorButtons; 100 | -------------------------------------------------------------------------------- /demos/anchoring_demo.py: -------------------------------------------------------------------------------- 1 | import random 2 | from uuid import uuid4 3 | from flask import ( 4 | Flask, 5 | session, 6 | request, 7 | redirect, 8 | url_for, 9 | render_template_string 10 | ) 11 | app = Flask(__name__) 12 | 13 | app.config.update(dict( 14 | DEBUG=True, 15 | SECRET_KEY='3.14159', # shhhhh 16 | )) 17 | 18 | from planout.experiment import SimpleExperiment 19 | from planout.ops.random import * 20 | 21 | class AnchoringExperiment(SimpleExperiment): 22 | def setup(self): 23 | self.set_log_file('anchoring_webapp.log') 24 | 25 | def assign(self, params, userid): 26 | params.use_round_number = BernoulliTrial(p=0.5, unit=userid) 27 | if params.use_round_number: 28 | params.price = UniformChoice(choices=[240000, 250000, 260000], 29 | unit=userid) 30 | else: 31 | params.price = RandomInteger(min=240000, max=260000, unit=userid) 32 | 33 | def money_format(number): 34 | return "${:,.2f}".format(number) 35 | 36 | @app.route('/') 37 | def main(): 38 | # if no userid is defined make one up 39 | if 'userid' not in session: 40 | session['userid'] = str(uuid4()) 41 | 42 | anchoring_exp = AnchoringExperiment(userid=session['userid']) 43 | price = anchoring_exp.get('price') 44 | 45 | return render_template_string(""" 46 | 47 | 48 | Let's buy a house! 49 | 50 | 51 |

52 | A lovely new home is going on the market for {{ price }}.
53 |

54 |

55 | What will be your first offer? 56 |

57 |
58 | $ 59 | 60 |
61 |
62 |

Reload without resetting my session ID. I'll get the same offer when I come back.

63 |

Reset my session ID so I get re-randomized into a new treatment.

64 | 65 | 66 | """, price=money_format(price)) 67 | 68 | @app.route('/reset') 69 | def reset(): 70 | session.clear() 71 | return redirect(url_for('main')) 72 | 73 | @app.route('/bid') 74 | def bid(): 75 | bid_string = request.args.get('bid') 76 | bid_string = bid_string.replace(',', '') # get rid of commas 77 | try: 78 | bid_amount = int(bid_string) 79 | 80 | anchoring_exp = AnchoringExperiment(userid=session['userid']) 81 | anchoring_exp.log_event('bid', {'bid_amount': bid_amount}) 82 | 83 | return render_template_string(""" 84 | 85 | 86 | Nice bid! 87 | 88 | 89 |

You bid {{ bid }}. We'll be in touch if they accept your offer!

90 |

Back

91 | 92 | 93 | """, bid=money_format(bid_amount)) 94 | except ValueError: 95 | return render_template_string(""" 96 | 97 | 98 | Bad bid! 99 | 100 | 101 |

You bid {{ bid }}. That's not a number, so we probably won't be accepting your bid.

102 |

Back

103 | 104 | 105 | """, bid=bid_string) 106 | 107 | 108 | if __name__ == '__main__': 109 | app.run() 110 | -------------------------------------------------------------------------------- /planout-editor/js/components/PlanOutTesterBoxForm.react.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014 Facebook, Inc. 3 | * 4 | * @providesModule PlanOutTesterBoxForm.react 5 | * @jsx React.DOM 6 | */ 7 | 8 | var React = require('react'); 9 | var ReactPropTypes = React.PropTypes; 10 | var Input = require('react-bootstrap/Input'); 11 | 12 | var PlanOutTesterActions = require('../actions/PlanOutTesterActions'); 13 | 14 | var PlanOutEditorConstants = require('../constants/PlanOutEditorConstants'); 15 | var TesterStatusCodes = PlanOutEditorConstants.TesterStatusCodes; 16 | var TesterBoxTypes = PlanOutEditorConstants.TesterBoxTypes; 17 | 18 | var PlanOutTesterBoxFormInput = require('./PlanOutTesterBoxFormInput.react'); 19 | 20 | 21 | var PlanOutTesterBoxForm = React.createClass({ 22 | propTypes: { 23 | id: ReactPropTypes.string.isRequired, 24 | assertions: ReactPropTypes.object, 25 | inputs: ReactPropTypes.object, 26 | overrides: ReactPropTypes.object, 27 | type: ReactPropTypes.string.isRequired 28 | }, 29 | 30 | render: function() { 31 | return ( 32 |
33 |
34 | {this.renderInputItem("Inputs", "inputs")} 35 | {this.renderInputItem("Overrides", "overrides")} 36 | { 37 | this.props.type === TesterBoxTypes.TEST ? 38 | this.renderInputItem("Assertions", "assertions") : null 39 | } 40 |
41 |
42 | ); 43 | }, 44 | 45 | renderInputItem: function(label, prop) { 46 | return ( 47 | 53 | ); 54 | }, 55 | 56 | 57 | // Parses JSON-encoded form element strings and returns object 58 | // containing each element: inputs, overrides, assertions 59 | // May be subbed out for other extract 60 | extractItemData: function() { 61 | var jsonBlob = { 62 | inputs: this.refs.inputs.getJSON(), 63 | overrides: this.refs.overrides.getJSON() 64 | }; 65 | if (this.props.type === TesterBoxTypes.TEST) { 66 | jsonBlob.assertions = this.refs.assertions.getJSON(); 67 | } 68 | 69 | for (var key in jsonBlob) { 70 | if (jsonBlob[key] === null) { 71 | return null; 72 | } 73 | } 74 | return jsonBlob; 75 | }, 76 | 77 | _updateJSON: function(event, ref) { 78 | this.refs[ref].updateJSON(event.target.value); 79 | }, 80 | 81 | _onChange: function(event, ref) { 82 | /* 83 | var payload = {}; 84 | payload[ref] = this.refs[ref].getJSON(); 85 | if (payload[ref]) { 86 | PlanOutTesterActions.updateTester( 87 | this.props.id, 88 | payload 89 | ); 90 | } 91 | */ 92 | 93 | 94 | var itemData = this.extractItemData(); 95 | // should probably add more granular checks to make sure 96 | // input data is given. these checks may not be necessary 97 | // once we move to better form UI components 98 | if (itemData) { 99 | PlanOutTesterActions.updateTester( 100 | this.props.id, 101 | { 102 | inputs: itemData.inputs, 103 | overrides: itemData.overrides, 104 | assertions: itemData.assertions 105 | } 106 | ); 107 | } 108 | } 109 | }); 110 | 111 | module.exports = PlanOutTesterBoxForm; 112 | -------------------------------------------------------------------------------- /python/docs/07-language.md: -------------------------------------------------------------------------------- 1 | # The PlanOut Language 2 | The PlanOut language is a way to concisely define experiments. 3 | PlanOut language code is compiled into JSON. The language is very basic: it contains basic logical operators, conditional execution, and arrays, but does not include things like loops and function definitions. This makes it easier to statically analyze experiments, and prevents users from shooting themselves in the foot. The syntax mostly resembles JavaScript. 4 | 5 | All variables set by PlanOut code are passed back via `Interpreter.get_params()` and by default, are automatically logged when used in conjunction with `SimpleInterpretedExperiment`. 6 | 7 | ## Overview 8 | * Lines are terminated with a `;` 9 | * Arrays are defined like `[1,2,3]`, and are indexed starting at `0`. 10 | * Random assignment operators (e.g., `uniformChoice`, `weightedChoice`, `bernoulliTrial`) require named parameters, and the ordering of parameter is arbitrary. 11 | * `True` and `False` values are equivalent to `1` and `0`. 12 | * `#`s are used to write comments 13 | * You can use the `PlanOutLanguageInspector` class to validate whether all PlanOut operators use the required and optional methods. 14 | 15 | ## Compiling PlanOut code 16 | PlanOut code can be compiled via the [Web-based compiler interface](http://facebook.github.io/planout/demo/planout-compiler.html) or using the node.js script in the `compiler/` directory of the PlanOut github repository: 17 | 18 | ``` 19 | node compiler/planout.js planoutscriptname 20 | ``` 21 | 22 | ## Built-in operators 23 | 24 | ### Random assignment operators 25 | The operators described in [...] can be used similar to how they are used in Python, except are lower case. The variable named given on the left hand side of an assignment operation is used as the `salt' given random assignment operators if no salt is specified manually. 26 | 27 | ``` 28 | colors = ['#aa2200', '#22aa00', '#0022aa']; 29 | x = uniformChoice(choices=colors, unit=userid); # 'x' used as salt 30 | y = uniformChoice(choices=colors, unit=userid); # 'y' used as salt, generally != x 31 | z = uniformChoice(choices=colors, unit=userid, salt='x'); # same value as x 32 | ``` 33 | 34 | 35 | ### Array operators 36 | Arrays can include constants and variables, and can be arbitrarily nested, and can contain arbitrary types. 37 | 38 | ``` 39 | a = [4, 5, 'foo']; 40 | b = [a, 2, 3]; # evaluates to [[1,2,'foo'], 2,3] 41 | x = a[0]; # evaluates to 4 42 | y = b[0][2] # evaluates to 'foo' 43 | l = length(b); # evaluates to 3 44 | ``` 45 | 46 | ### Logical operators 47 | Logical operators include *and* (`&&`), *or* (`||`), *not* (`!`), as in: 48 | 49 | ``` 50 | a = 1; b = 0; c = 1; 51 | x = a && b; # evaluates to False 52 | y = a || b || c; # evaluates to True 53 | y = !b; # evaluates to True 54 | ``` 55 | 56 | ### Control flow 57 | PlanOut supports if / else if / else. 58 | ``` 59 | if (country == 'US') { 60 | p = 0.2; 61 | } else if (country == 'UK') { 62 | p = 0.4; 63 | } else { 64 | p = 0.1; 65 | } 66 | ``` 67 | 68 | ### Arithmetic 69 | Current arithmetic operators supported in the PlanOut language are: addition, subtraction, modulo, multiplication, and division. 70 | ``` 71 | a = 2 + 3 + 4; # 9 72 | b = 2 * 3 * 4; # 24 73 | c = -2; # -2 74 | d = 2 + 3 - 4; # 1 75 | e = 4 % 2; # 0 76 | f = 4 / 2; # 2.0 77 | ``` 78 | 79 | ### Other operators 80 | Other operators that are part of the core language include `min` and `max`: 81 | ``` 82 | x = min(1, 2, -4); # -4 83 | y = min([1, 2, -4]) # -4 84 | y = max(1, 2, -4) # 2 85 | ``` 86 | -------------------------------------------------------------------------------- /python/docs/05.1-simple-namespaces.md: -------------------------------------------------------------------------------- 1 | # Iterating on experiments with `SimpleNamespace` 2 | 3 | `SimpleNamespace` provides namespace functionality without being backed by a database or involving a larger experimentation management system. For both organizational and performance reasons, it is not recommended for namespaces that would be used to run many (e.g., thousands) experiments, but it should be sufficient for iterating on smaller-scale experiments. 4 | 5 | Similar to how you create experiments with the `SimpleExperiment` class, new namespaces are created through subclassing. `SimpleNamespace` two requires that developers implement two methods: 6 | - `setup_attributes()`: this method sets the namespace's name, the primary unit, and number of segments. The primary unit is the input unit that gets mapped to segments, which are allocated to experiments. 7 | - `setup_experiments()`: this method allocates and deallocates experiments. When used in production, lines of code should only be added to this method. 8 | 9 | ```python 10 | class ButtonNamespace(SimpleNamespace): 11 | def setup_attributes(self): 12 | self.name = 'my_demo' 13 | self.primary_unit = 'userid' 14 | self.num_segments = 1000 15 | 16 | def setup_experiments(): 17 | # create and remove experiments here 18 | ``` 19 | 20 | In the example above, the name of the namespace is `my_demo`. This gets used, in addition to the experiment name and variable names, to hash units to experimental conditions. The number of segments is the granularity of the experimental groups. 21 | 22 | ### Allocating and deallocating segments to experiments 23 | When you extend `SimpleNamespace` class, you implement the `setup_experiments` method. This specifies a series of allocations and deallocations of segments to experiments. 24 | 25 | For example, the following setup method: 26 | ```python 27 | def setup_experiments(): 28 | self.add_experiment('first experiment', MyFirstExperiment, 100) 29 | ``` 30 | would allocate 'first experiment' is the name of your experiment, 100 of 1000 segments, selected at random to be allocated to the `MyFirstExperiment` class. In this example (see demo/demo_namespace.py), `MyFirstExperiment` is a subclass of `SimpleExperiment`. 31 | 32 | Adding additional experiments would just involve appending additional lines to the method. For example, you might run a larger follow-up experiment with the same design: 33 | ```python 34 | def setup_experiments(): 35 | self.add_experiment('first experiment', MyFirstExperiment, 10) 36 | self.add_experiment('first experiment, replication 1', MyFirstExperiment, 40) 37 | ``` 38 | Or you might run a new experiment with a different design: 39 | ```python 40 | def setup_experiments(): 41 | self.add_experiment('first experiment', MyFirstExperiment, 10) 42 | self.add_experiment('second experiment', MySecondExperiment, 20) 43 | ``` 44 | When an experiment is complete and you wish to make its segments available to new experiments, append a call to `remove_experiment`: 45 | ```python 46 | def setup_experiments(): 47 | self.add_experiment('first experiment', MyFirstExperiment, 10) 48 | self.add_experiment('second experiment', MySecondExperiment, 20) 49 | self.remove_experiment('first experiment') 50 | self.add_experiment('third experiment', MyThirdExperiment, 50) 51 | ``` 52 | Note: In modifying this method, you should only add lines after all previous lines. Inserting calls to `add_experiment` or `remove_experiment` will likely move segments from one experiment to another -- not what you want! This is because there is no stored allocation state (e.g., in a database), so the current allocation is dependent on the full history. 53 | -------------------------------------------------------------------------------- /python/docs/03-how-planout-works.md: -------------------------------------------------------------------------------- 1 | # How PlanOut works 2 | 3 | PlanOut works by hashing input data into numbers, and using these numbers to generate what are effectively pseudo-random values to pick numbers. All PlanOut operators include basic unit tests (link) to verify that they generate assignments with the expected distribution. 4 | 5 | Good randomization procedures produce assignments that are independent of one another. Below, we show how PlanOut uses experiment-level and variable-level "salts" (strings that get appended to the data thats being hashed) to make sure that variables within and across experiments remain independent. 6 | 7 | ## Pseudo-random assignment through hashing 8 | Consider the following experiment: 9 | ```python 10 | class SharingExperiment(SimpleExperiment): 11 | def set_attributes(self): 12 | self.name = 'sharing_name' 13 | self.salt = 'sharing_salt' 14 | 15 | def assign(self, params, userid): 16 | params.button_text = UniformChoice( 17 | choices=['OK', 'Share', 'Share with friends'], 18 | unit=userid 19 | ) 20 | ``` 21 | Here, we define a single randomized parameter, `button_text`. The assignment is generated by first composing a string containing experiment-level salt, `sharing_salt`, the parameter-level salt `button_text`, and the input unit. By default, PlanOut uses the variable name as the parameter-level salt. 22 | 23 | When we choose the button text for a particular unit, e.g., 24 | 25 | ```python 26 | SharingExperiment(userid=4).get('button_text') 27 | ``` 28 | 29 | PlanOut would compute the SHA1 checksum for: 30 | ``` 31 | sharing_salt.button_text.4 32 | ``` 33 | and then use the last few digits of this checksum to index into the given list of `choices`. Since SHA1 is cryptographically safe, even minor changes to the hashing string (e.g., considering `userid=41` instead of 4) will result in a totally different number. 34 | 35 | Multiple units are handled through concatenation. Had the `unit` parameter been `unit=[userid, url]`, 36 | 37 | ```python 38 | SharingExperiment(userid=4, url='http://www.facebook.com').get('button_text') 39 | ``` 40 | 41 | PlanOut would compute the SHA1 checksum for: 42 | ``` 43 | sharing_salt.button_text.4.http://www.facebook.com 44 | ``` 45 | 46 | Note that because PlanOut simply concatenates the units, the order in which you specify lists of units matters. 47 | 48 | 49 | ## Salts 50 | 51 | ### Experiment-level salts 52 | Experiment-level salts can be manually in the `set_attributes()` method (as we have above). If the salt is not specified, then the experiment name is used as the salt. With SimpleExperiment, if the name is not set in `set_attributes()`, then the name of the class is used as the experiment name. 53 | 54 | ### Parameter-level salts 55 | The parameter name is automatically used to salt random assignment operations, but parameter level salts can be specified manually. For example, in the following code 56 | 57 | ```python 58 | params.x = UniformChoice(choices=['a','b'], unit=userid) 59 | params.y = UniformChoice(choices=['a','b'], unit=userid, salt='x') 60 | ``` 61 | 62 | both `x` and `y` will always be assigned to the same exact same value. 63 | 64 | This lets you change the name of the variable you are logging without changing the assignment. Use variable level salts with caution, since they might lead to failures in randomization (link). 65 | 66 | ### Salts with namespaces 67 | Namespaces (link) are a way to manage concurrent and iterative experiments. When using `SimpleNamespace`, the namespace-level salt is appended to the experiment-level salt. This ensures that random assignment is independent across experiments with the same name running under different namespaces. 68 | -------------------------------------------------------------------------------- /python/planout/ops/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import six 3 | 4 | class StopPlanOutException(Exception): 5 | 6 | """Exception that gets raised when "return" op is evaluated""" 7 | 8 | def __init__(self, in_experiment): 9 | self.in_experiment = in_experiment 10 | 11 | 12 | class Operators(): 13 | """Singleton class for inspecting and registering operators""" 14 | 15 | @staticmethod 16 | def initFactory(): 17 | from . import core, random 18 | Operators.operators = { 19 | "literal": core.Literal, 20 | "get": core.Get, 21 | "seq": core.Seq, 22 | "set": core.Set, 23 | "return": core.Return, 24 | "index": core.Index, 25 | "array": core.Array, 26 | "map": core.Map, 27 | "equals": core.Equals, 28 | "cond": core.Cond, 29 | "and": core.And, 30 | "or": core.Or, 31 | ">": core.GreaterThan, 32 | "<": core.LessThan, 33 | ">=": core.GreaterThanOrEqualTo, 34 | "<=": core.LessThanOrEqualTo, 35 | "%": core.Mod, 36 | "/": core.Divide, 37 | "not": core.Not, 38 | "round": core.Round, 39 | "negative": core.Negative, 40 | "min": core.Min, 41 | "max": core.Max, 42 | "length": core.Length, 43 | "coalesce": core.Coalesce, 44 | "product": core.Product, 45 | "sum": core.Sum, 46 | "exp": core.Exp, 47 | "sqrt": core.Sqrt, 48 | "randomFloat": random.RandomFloat, 49 | "randomInteger": random.RandomInteger, 50 | "bernoulliTrial": random.BernoulliTrial, 51 | "bernoulliFilter": random.BernoulliFilter, 52 | "uniformChoice": random.UniformChoice, 53 | "weightedChoice": random.WeightedChoice, 54 | "sample": random.Sample, 55 | "fastSample": random.FastSample 56 | } 57 | 58 | @staticmethod 59 | def registerOperators(operators): 60 | for op, obj in six.iteritems(operators): 61 | assert op not in Operators.operators 62 | Operators.operators[op] = operators[op] 63 | 64 | @staticmethod 65 | def isOperator(op): 66 | return type(op) is dict and "op" in op 67 | 68 | @staticmethod 69 | def operatorInstance(params): 70 | op = params['op'] 71 | assert (op in Operators.operators), "Unknown operator: %s" % op 72 | return Operators.operators[op](**params) 73 | 74 | @staticmethod 75 | def prettyParamFormat(params): 76 | ps = [p + '=' + Operators.pretty(params[p]) 77 | for p in params if p != 'op'] 78 | return ', '.join(ps) 79 | 80 | @staticmethod 81 | def strip_array(params): 82 | if type(params) is list: 83 | return params 84 | if type(params) is dict and params.get('op', None) == 'array': 85 | return params['values'] 86 | else: 87 | return params 88 | 89 | @staticmethod 90 | def pretty(params): 91 | if Operators.isOperator(params): 92 | try: 93 | # if an op is invalid, we may not be able to pretty print it 94 | my_pretty = Operators.operatorInstance(params).pretty() 95 | except: 96 | my_pretty = params 97 | return my_pretty 98 | elif type(params) is list: 99 | return '[%s]' % ', '.join([Operators.pretty(p) for p in params]) 100 | else: 101 | return json.dumps(params) 102 | -------------------------------------------------------------------------------- /python/docs/04-logging.md: -------------------------------------------------------------------------------- 1 | # Logging 2 | 3 | You will usually want to log which units (e.g., users) are exposed to your experiment. 4 | 5 | Logging this information enables monitoring your experiment and improving your analysis of the results. In particular, many experiments only change your service for the small portion of users that use a particular part of the service; keeping track of these users will make your analysis more precise. 6 | 7 | The `SimpleExperiment` class providing functionality for logging data to a file using Python's `logger` module. Additionally, you can extend `Experiment` to call your logging code instead (link to extending-logging). 8 | 9 | ## Anatomy of a log 10 | Consider Experiment 1 from the PlanOut paper (link): 11 | ``` 12 | class Exp1(SimpleExperiment): 13 | def assign(self, e, userid): 14 | e.group_size = UniformChoice(choices=[1, 10], unit=userid); 15 | e.specific_goal = BernoulliTrial(p=0.8, unit=userid); 16 | if e.specific_goal: 17 | e.ratings_per_user_goal = UniformChoice( 18 | choices=[8, 16, 32, 64], unit=userid) 19 | e.ratings_goal = e.group_size * e.ratings_per_user_goal 20 | return e 21 | ``` 22 | It takes a `userid` as input, and assigns three paramers, `group_size`, `specific_goal`, and `ratings goal`. It does not specify a custom salt (link) or experiment name, so the experiment salt and name are automatically set to the class name `Exp3`. The default logger in `SimpleExperiment` log all of these fields: 23 | 24 | ``` 25 | {'inputs': {'userid': 3}, 'checksum': '4b80881d', 'salt': 'Exp1', 'name': 'Exp1', 'params': {'specific_goal': 1, 'ratings_goal': 160, 'group_size': 10, 'ratings_per_user_goal': 16}} 26 | ``` 27 | 28 | In addition, a `checksum` field is attached. This is part of an MD5 checksum of your code, so that analysts can keep track of when an experiments' assignments have potentially changed: whenever the checksum changes, the assignment procedure code is different, and whenever the salt changes, the assignments will be completely different. 29 | 30 | TODO: exposure-log logs should have a field that specifies its an exposure. This payload should also include a timestamp. 31 | 32 | ## Types of logs 33 | 34 | ### Auto-exposure logging 35 | By default, exposures are logged once per instance of an experiment object when you get a parameter. This is auto-exposure logging. It is recommended for most situations, since you will want to track whenever a unit is exposed to your experiment. Generally, any unit for which a parameter has been retrieved should be counted as exposed, unless you wish to make further assumptions. 36 | 37 | ### Manual exposure logging 38 | In some cases, you might want to choose exactly when exposures are logged. You can disable auto-exposure logging with the `set_auto_exposure_logging` method and instead choose to directly call `log_exposure` to keep track of exposures. 39 | 40 | Why might you want to do this? You might be adding experimental assignment information to other existing logs of outcome data, but many of the users who have outcome observations may not actually have been exposed. Other cases occur when some advance preparation of some components (e.g., UI) or data are required, but you can assume that parameter values set at this stage do not yet affect the user. 41 | 42 | ### Conversion logging 43 | You want to see how the parameters you are manipulating affect outcomes or conversion events. It can also be convenient to log these events along with exposures. You can do this by calling the `log_outcome` method. 44 | 45 | You may have existing logs for many of these events. In this case, you could add experimental assignment information to these logs by instantiating an experiment, turning off auto-exposure logging for that instance, and adding parameter information to your logs. Alternatively, you can later join exposure and outcome data on unit identifiers (e.g., user IDs). 46 | -------------------------------------------------------------------------------- /python/planout/interpreter.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014, Facebook, Inc. 2 | # All rights reserved. 3 | # 4 | # This source code is licensed under the BSD-style license found in the 5 | # LICENSE file in the root directory of this source tree. An additional grant 6 | # of patent rights can be found in the PATENTS file in the same directory. 7 | 8 | from copy import deepcopy 9 | from .ops.utils import Operators, StopPlanOutException 10 | from .assignment import Assignment 11 | 12 | 13 | Operators.initFactory() 14 | 15 | 16 | class Interpreter(object): 17 | 18 | """PlanOut interpreter""" 19 | 20 | def __init__(self, serialization, experiment_salt='global_salt', 21 | inputs={}, environment=None): 22 | self._serialization = serialization 23 | if environment is None: 24 | self._env = Assignment(experiment_salt) 25 | else: 26 | self._env = environment 27 | self.experiment_salt = self._experiment_salt = experiment_salt 28 | self._evaluated = False 29 | self._in_experiment = True 30 | self._inputs = inputs.copy() 31 | 32 | def register_operators(self, operators): 33 | Operators.registerOperators(operators) 34 | return self 35 | 36 | def get_params(self): 37 | """Get all assigned parameter values from an executed interpreter script""" 38 | # evaluate code if it hasn't already been evaluated 39 | if not self._evaluated: 40 | try: 41 | self.evaluate(self._serialization) 42 | except StopPlanOutException as e: 43 | # StopPlanOutException is raised when script calls "return", which 44 | # short circuits execution and sets in_experiment 45 | self._in_experiment = e.in_experiment 46 | self._evaluated = True 47 | return self._env 48 | 49 | @property 50 | def in_experiment(self): 51 | return self._in_experiment 52 | 53 | @property 54 | def salt_sep(self): 55 | return self._env.salt_sep 56 | 57 | def set_env(self, new_env): 58 | """Replace the current environment with a dictionary""" 59 | self._env = deepcopy(new_env) 60 | # note that overrides are inhereted from new_env 61 | return self 62 | 63 | def has(self, name): 64 | """Check if a variable exists in the PlanOut environment""" 65 | return name in self._env 66 | 67 | def get(self, name, default=None): 68 | """Get a variable from the PlanOut environment""" 69 | return self._env.get(name, self._inputs.get(name, default)) 70 | 71 | def set(self, name, value): 72 | """Set a variable in the PlanOut environment""" 73 | self._env[name] = value 74 | return self 75 | 76 | def set_overrides(self, overrides): 77 | """ 78 | Sets variables to maintain a frozen state during the interpreter's 79 | execution. This is useful for debugging PlanOut scripts. 80 | """ 81 | self._env.set_overrides(overrides) 82 | return self 83 | 84 | def get_overrides(self): 85 | """Get a dictionary of all overrided values""" 86 | return self._env.get_overrides() 87 | 88 | def has_override(self, name): 89 | """Check to see if a variable has an override.""" 90 | return name in self.get_overrides() 91 | 92 | def evaluate(self, planout_code): 93 | """Recursively evaluate PlanOut interpreter code""" 94 | # if the object is a PlanOut operator, execute it it. 95 | if type(planout_code) is dict and 'op' in planout_code: 96 | return Operators.operatorInstance(planout_code).execute(self) 97 | # if the object is a list, iterate over the list and evaluate each 98 | # element 99 | elif type(planout_code) is list: 100 | return [self.evaluate(i) for i in planout_code] 101 | else: 102 | return planout_code # data is a literal 103 | -------------------------------------------------------------------------------- /planout-editor/js/components/PlanOutScriptPanel.react.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014 Facebook, Inc. 3 | * 4 | * @providesModule PlanOutScriptPanel.react 5 | * @jsx React.DOM 6 | */ 7 | 8 | var React = require('react'); 9 | 10 | var PlanOutExperimentActions = require('../actions/PlanOutExperimentActions'); 11 | var PlanOutExperimentStore = require('../stores/PlanOutExperimentStore'); 12 | 13 | 14 | function getStateFromStores() { 15 | return { 16 | compilerMessage: PlanOutExperimentStore.getCompilerMessage(), 17 | doesCompile: PlanOutExperimentStore.doesCompile(), 18 | inputVariables: PlanOutExperimentStore.getInputVariables(), 19 | json: PlanOutExperimentStore.getJSON(), 20 | params: PlanOutExperimentStore.getParams(), 21 | script: PlanOutExperimentStore.getScript() 22 | }; 23 | } 24 | 25 | var PlanOutScriptPanel = React.createClass({ 26 | 27 | getInitialState: function() /*object*/ { 28 | var state_data = getStateFromStores(); 29 | state_data.showCompiledBlock = false; 30 | return state_data; 31 | }, 32 | 33 | componentDidMount: function() { 34 | PlanOutExperimentStore.addChangeListener(this._onChange); 35 | }, 36 | 37 | componentWillUnmount: function() { 38 | PlanOutExperimentStore.removeChangeListener(this._onChange); 39 | }, 40 | 41 | _onChange: function() { 42 | this.setState(getStateFromStores()); 43 | }, 44 | 45 | 46 | render: function() { 47 | return ( 48 |
49 | {this.renderCodeBlock()} 50 | 55 | {this.renderScriptStatus()} 56 | 58 |
59 | ); 60 | }, 61 | 62 | renderCodeBlock: function() { 63 | if (this.state.showCompiledBlock) { 64 | return this.renderCompiledBlock(); 65 | } else { 66 | return this.renderScriptInputBlock(); 67 | } 68 | }, 69 | 70 | renderScriptStatus: function() { 71 | return ( 72 |
73 |
Compilation status
74 |
{this.renderCompileStatus()}
75 |
Input units
76 |
{this.state.inputVariables.join(', ')}
77 |
Parameters
78 |
{this.state.params.join(', ')}
79 |
80 | ); 81 | }, 82 | 83 | /** 84 | * This could be swapped out with various open-source components. 85 | */ 86 | renderScriptInputBlock: function() { 87 | return ( 88 | 81 | 82 | 90 | 91 |

92 | JavaScript mode supports several configuration options: 93 |

    94 |
  • json which will set the mode to expect JSON 95 | data rather than a JavaScript program.
  • 96 |
  • jsonld which will set the mode to expect 97 | JSON-LD linked data rather 98 | than a JavaScript program (demo).
  • 99 |
  • typescript which will activate additional 100 | syntax highlighting and some other things for TypeScript code 101 | (demo).
  • 102 |
  • statementIndent which (given a number) will 103 | determine the amount of indentation to use for statements 104 | continued on a new line.
  • 105 |
106 |

107 | 108 |

MIME types defined: text/javascript, application/json, application/ld+json, text/typescript, application/typescript.

109 | 110 | -------------------------------------------------------------------------------- /planout-editor/static/js/mode/javascript/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | CodeMirror: JavaScript mode 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 27 | 28 |
29 |

JavaScript mode

30 | 31 | 32 |
81 | 82 | 90 | 91 |

92 | JavaScript mode supports several configuration options: 93 |

    94 |
  • json which will set the mode to expect JSON 95 | data rather than a JavaScript program.
  • 96 |
  • jsonld which will set the mode to expect 97 | JSON-LD linked data rather 98 | than a JavaScript program (demo).
  • 99 |
  • typescript which will activate additional 100 | syntax highlighting and some other things for TypeScript code 101 | (demo).
  • 102 |
  • statementIndent which (given a number) will 103 | determine the amount of indentation to use for statements 104 | continued on a new line.
  • 105 |
106 |

107 | 108 |

MIME types defined: text/javascript, application/json, application/ld+json, text/typescript, application/typescript.

109 |
110 | -------------------------------------------------------------------------------- /python/docs/06-best-practices.md: -------------------------------------------------------------------------------- 1 | # Best practices 2 | 3 | PlanOut makes it easy to implement bug-free code that randomly assigns users (or other units) to parameters. The Experiment and Namespace classes are designed to reduce common errors in deploying and logging experiments. Here are a few tips for running experiments: 4 | 5 | * Use auto-exposure logging (link), which is enabled by default. Auto-exposure logging makes it easier to check that your assignment procedure is working correctly, increases the precision of your experiment, and reduces errors in downstream analysis. 6 | * Avoid changing an experiment while it is running. Instead, either run a follow-up experiment using a namespace (link), or create a new experiment with a different salt to re-randomize the units. These experiments should be analyzed separately from the original experiment. 7 | * Automate the analysis of your experiment. If you are running multiple related experiments, create a pipeline to automatically do the analysis. 8 | 9 | 10 | There are no hard and fast rules for what kinds of changes are actually a problem, but if you follow the best practices above, you should be in reasonable shape. 11 | 12 | 13 | ## Randomization failures 14 | Experiments are used to test the change of one or more parameters on some average outcome (e.g., messages sent, or clicks on a button). Differences can be safely attributed to a change in parameters if treatments are assigned to users completely at random. 15 | 16 | In practice, there are a number of common ways for two groups to not be equivalent (beyond random imbalance): 17 | - Some units from one group were previously in a different group, while users from the other group were not. 18 | - Some units in one group were recently added to the experiment. 19 | - There was a bug in the code for one group but not the other, and that bug recently got fixed. 20 | 21 | In these cases, we suggest that you launch a new experiment, either through the use of namespaces (links), or by re-assigning all of the units in your experiment. This can be done by simply changing the salt of your experiment: 22 | 23 | ```python 24 | class MyNewExperiment(MyOldExperiment): 25 | def set_experiment_properties(self): 26 | self.name = 'new_experiment_name' 27 | self.salt = 'new_experiment_salt' 28 | ``` 29 | 30 | 31 | ### Unanticipated consequences from changing experiments 32 | Changes to experiment definitions will generally alter which parameters users are assigned to. For example, consider an experiment that manipulates the label of a button for sharing a link. The main outcome of interest is the effect of this text on how many links users share per day. 33 | 34 | 35 | ```python 36 | class SharingExperiment(SimpleExperiment): 37 | def assign(self, params, userid): 38 | params.button_text = UniformChoice( 39 | choices=['OK', 'Share', 'Share with friends'], 40 | unit=userid 41 | ) 42 | ``` 43 | Changing the variable name `button_text` changes the assignment, since it is used to salt (link) to assignment procedure (see `Experiment` intro document). 44 | 45 | Changing the number of choices for the `button_text` also affects users previously randomized into other conditions. For example, removing the 'Share' item from the `choices` list, will allocate some users who were previosuly in the 'Share' condition to the 'OK' and 'Share with friends group'. Their outcomes will now be a weighted average of the two, which may decrease the observed difference between 'OK' and 'Share with friends'. 46 | 47 | If an additional choice were added to `choices`, some percentage of each prior choice would be allocated to the new choice, whose outcome represents an average of all groups. Comparisons between users still in the old groups (the newly added parameters may be subject to greater novelty effects. 48 | 49 | ## Detecting problems 50 | If you suspect your experiment might have changed, check the `salt` and `checksum` fields of your log. If either of these items change, it is likely that your assignments have also changed mid-way through the experiment. 51 | 52 | ## Learn more 53 | [Designing and Deploying Online Field Experiments](http://www-personal.umich.edu/~ebakshy/planout.pdf). WWW 2014. Eytan Bakshy, Dean Eckles, Michael S. Bernstein. 54 | [Seven Pitfalls to Avoid when Running Controlled Experiments on the Web.](http://www.exp-platform.com/Documents/2009-ExPpitfalls.pdf) KDD 2009. Thomas Crook, Brian Frasca, Ron Kohavi, and Roger Longbotham. 55 | -------------------------------------------------------------------------------- /python/planout/ops/random.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import six 3 | from .base import PlanOutOpSimple 4 | 5 | 6 | class PlanOutOpRandom(PlanOutOpSimple): 7 | LONG_SCALE = float(0xFFFFFFFFFFFFFFF) 8 | 9 | def getUnit(self, appended_unit=None): 10 | unit = self.getArgMixed('unit') 11 | if type(unit) is not list: 12 | unit = [unit] 13 | if appended_unit is not None: 14 | unit += [appended_unit] 15 | return unit 16 | 17 | def getHash(self, appended_unit=None): 18 | if 'full_salt' in self.args: 19 | full_salt = self.getArgString('full_salt') + '.' # do typechecking 20 | else: 21 | full_salt = '%s.%s%s' % ( 22 | self.mapper.experiment_salt, 23 | self.getArgString('salt'), 24 | self.mapper.salt_sep) 25 | 26 | unit_str = '.'.join(map(str, self.getUnit(appended_unit))) 27 | hash_str = '%s%s' % (full_salt, unit_str) 28 | if not isinstance(hash_str, six.binary_type): 29 | hash_str = hash_str.encode("ascii") 30 | return int(hashlib.sha1(hash_str).hexdigest()[:15], 16) 31 | 32 | def getUniform(self, min_val=0.0, max_val=1.0, appended_unit=None): 33 | zero_to_one = self.getHash(appended_unit) / PlanOutOpRandom.LONG_SCALE 34 | return min_val + (max_val - min_val) * zero_to_one 35 | 36 | 37 | class RandomFloat(PlanOutOpRandom): 38 | 39 | def simpleExecute(self): 40 | min_val = self.getArgFloat('min') 41 | max_val = self.getArgFloat('max') 42 | 43 | return self.getUniform(min_val, max_val) 44 | 45 | 46 | class RandomInteger(PlanOutOpRandom): 47 | 48 | def simpleExecute(self): 49 | min_val = self.getArgInt('min') 50 | max_val = self.getArgInt('max') 51 | 52 | return min_val + self.getHash() % (max_val - min_val + 1) 53 | 54 | 55 | class BernoulliTrial(PlanOutOpRandom): 56 | 57 | def simpleExecute(self): 58 | p = self.getArgNumeric('p') 59 | assert p >= 0 and p <= 1.0, \ 60 | '%s: p must be a number between 0.0 and 1.0, not %s!' \ 61 | % (self.__class__, p) 62 | 63 | rand_val = self.getUniform(0.0, 1.0) 64 | return 1 if rand_val <= p else 0 65 | 66 | 67 | class BernoulliFilter(PlanOutOpRandom): 68 | 69 | def simpleExecute(self): 70 | p = self.getArgNumeric('p') 71 | values = self.getArgList('choices') 72 | assert p >= 0 and p <= 1.0, \ 73 | '%s: p must be a number between 0.0 and 1.0, not %s!' \ 74 | % (self.__class__, p) 75 | 76 | if len(values) == 0: 77 | return [] 78 | return [i for i in values if self.getUniform(0.0, 1.0, i) <= p] 79 | 80 | 81 | class UniformChoice(PlanOutOpRandom): 82 | 83 | def simpleExecute(self): 84 | choices = self.getArgList('choices') 85 | 86 | if len(choices) == 0: 87 | return [] 88 | rand_index = self.getHash() % len(choices) 89 | return choices[rand_index] 90 | 91 | 92 | class WeightedChoice(PlanOutOpRandom): 93 | 94 | def simpleExecute(self): 95 | choices = self.getArgList('choices') 96 | weights = self.getArgList('weights') 97 | 98 | if len(choices) == 0: 99 | return [] 100 | cum_weights = dict(enumerate(weights)) 101 | cum_sum = 0.0 102 | for index in cum_weights: 103 | cum_sum += cum_weights[index] 104 | cum_weights[index] = cum_sum 105 | stop_value = self.getUniform(0.0, cum_sum) 106 | for index in cum_weights: 107 | if stop_value <= cum_weights[index]: 108 | return choices[index] 109 | 110 | 111 | class BaseSample(PlanOutOpRandom): 112 | 113 | def copyChoices(self): 114 | return [x for x in self.getArgList('choices')] 115 | 116 | def getNumDraws(self, choices): 117 | if 'draws' in self.args: 118 | num_draws = self.getArgInt('draws') 119 | assert num_draws <= len(choices), \ 120 | "%s: cannot make %s draws when only %s choices are available" \ 121 | % (self.__class__, num_draws, len(choices)) 122 | return num_draws 123 | else: 124 | return len(choices) 125 | 126 | class FastSample(BaseSample): 127 | 128 | def simpleExecute(self): 129 | choices = self.copyChoices() 130 | num_draws = self.getNumDraws(choices) 131 | stopping_point = len(choices) - num_draws 132 | 133 | for i in six.moves.range(len(choices) - 1, 0, -1): 134 | j = self.getHash(i) % (i + 1) 135 | choices[i], choices[j] = choices[j], choices[i] 136 | if stopping_point == i: 137 | return choices[i:] 138 | return choices[:num_draws] 139 | 140 | class Sample(BaseSample): 141 | 142 | def simpleExecute(self): 143 | choices = self.copyChoices() 144 | num_draws = self.getNumDraws(choices) 145 | 146 | for i in six.moves.range(len(choices) - 1, 0, -1): 147 | j = self.getHash(i) % (i + 1) 148 | choices[i], choices[j] = choices[j], choices[i] 149 | return choices[:num_draws] 150 | -------------------------------------------------------------------------------- /planout-editor/js/components/PlanOutTesterBox.react.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014 Facebook, Inc. 3 | * 4 | * @providesModule PlanOutTesterBox.react 5 | * @jsx React.DOM 6 | */ 7 | 8 | var React = require('react'); 9 | var ReactPropTypes = React.PropTypes; 10 | 11 | var PlanOutTesterActions = require('../actions/PlanOutTesterActions'); 12 | var PlanOutTesterBoxForm = require('./PlanOutTesterBoxForm.react'); 13 | var PlanOutTesterBoxOutput = require('./PlanOutTesterBoxOutput.react'); 14 | 15 | var PlanOutEditorConstants = require('../constants/PlanOutEditorConstants'); 16 | var TesterBoxTypes = PlanOutEditorConstants.TesterBoxTypes; 17 | var TesterStatusCodes = PlanOutEditorConstants.TesterStatusCodes; 18 | 19 | //var Bootstrap = require('react-bootstrap'); 20 | //var Panel = Bootstrap.Panel; 21 | 22 | 23 | var PlanOutTesterBox = React.createClass({ 24 | propTypes: { 25 | id: ReactPropTypes.string.isRequired, 26 | assertions: ReactPropTypes.object, 27 | errors: ReactPropTypes.array, 28 | inputs: ReactPropTypes.object, 29 | overrides: ReactPropTypes.object, 30 | results: ReactPropTypes.object, 31 | status: ReactPropTypes.string.isRequired, 32 | type: ReactPropTypes.string.isRequired 33 | }, 34 | 35 | getInitialState: function() { 36 | return {expanded: this.props.type === TesterBoxTypes.PLAYGROUND}; 37 | }, 38 | 39 | _toggleExpand: function() { 40 | this.setState({expanded: !this.state.expanded}); 41 | }, 42 | 43 | _destroy: function() { 44 | PlanOutTesterActions.destroy(this.props.id); 45 | }, 46 | 47 | render: function() { 48 | var titleBarSettings = this.getTitleBarStrings(); 49 | 50 | var collapse_class = "panel-collapse collapse"; 51 | collapse_class += this.state.expanded ? " in" : ""; 52 | 53 | return ( 54 |
55 |
56 |
57 |

58 | 59 | {titleBarSettings.title} 60 | 61 |   62 | {this.renderDestroy()} 63 |

64 |
65 |
66 |
67 | {this.renderPlanOutTesterBox()} 68 |
69 |
70 |
71 |
72 | ); 73 | /* 74 | When react-bootstrap becomes more stable, the above will be replaced with 75 | 76 | return ( 77 | 83 | {this.renderPlanOutTesterBox()} 84 | 85 | ); 86 | */ 87 | }, 88 | 89 | renderPlanOutTesterBox: function() { 90 | return ( 91 |
92 | 99 | 103 |
104 | ); 105 | }, 106 | 107 | renderDestroy: function() { 108 | // playground cannot be nuked 109 | if (this.props.type !== TesterBoxTypes.PLAYGROUND) { 110 | return [x]; 111 | } else { 112 | return; 113 | } 114 | }, 115 | 116 | // generate strings for accordian panel title 117 | getTitleBarStrings: function() { 118 | var title; 119 | switch(this.props.status) { 120 | case TesterStatusCodes.INVALID_FORM: 121 | return { 122 | panel_style: "panel-warning", 123 | title: "Invalid JSON input" 124 | }; 125 | case TesterStatusCodes.PENDING: 126 | return { 127 | panel_style: "panel-warning", 128 | title: "Pending results..." 129 | } 130 | case TesterStatusCodes.FAILURE: 131 | if (this.props.errors[0].error_code === 'runtime') { 132 | title = "Runtime error"; 133 | } else if (this.props.errors[0].error_code === 'assertion') { 134 | title = "Failed assertion"; 135 | } else { 136 | title = "Unknown other error"; 137 | console.log("Unknown errors:", this.props.errors); 138 | } 139 | return { 140 | panel_style: "panel-danger", 141 | title: "Test status: " + title 142 | }; 143 | case TesterStatusCodes.SUCCESS: 144 | if (this.props.type === TesterBoxTypes.TEST) { 145 | return { 146 | panel_style: "panel-success", 147 | title: "Test status: Success"}; 148 | } else { 149 | return { 150 | panel_style: "panel-default", 151 | title: "Playground" 152 | }; 153 | } 154 | default: 155 | console.log("Unknown status code:", this.props.status); 156 | } 157 | } 158 | }); 159 | 160 | 161 | module.exports = PlanOutTesterBox; 162 | -------------------------------------------------------------------------------- /python/planout/ops/base.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014, Facebook, Inc. 2 | # All rights reserved. 3 | # 4 | # This source code is licensed under the BSD-style license found in the 5 | # LICENSE file in the root directory of this source tree. An additional grant 6 | # of patent rights can be found in the PATENTS file in the same directory. 7 | 8 | import logging 9 | from abc import ABCMeta, abstractmethod 10 | import six 11 | 12 | from .utils import Operators 13 | 14 | 15 | class PlanOutOp(object): 16 | 17 | """Abstract base class for PlanOut Operators""" 18 | __metaclass__ = ABCMeta 19 | # all PlanOut operator have some set of args that act as required and 20 | # optional arguments 21 | 22 | def __init__(self, **args): 23 | self.args = args 24 | 25 | # all PlanOut operators must implement execute 26 | @abstractmethod 27 | def execute(self, mapper): 28 | pass 29 | 30 | def prettyArgs(self): 31 | return Operators.prettyParamFormat(self.args) 32 | 33 | def pretty(self): 34 | return '%s(%s)' % (self.args['op'], self.prettyArgs()) 35 | 36 | def getArgMixed(self, name): 37 | assert name in self.args, \ 38 | "%s: missing argument: %s." % (self.__class__, name) 39 | return self.args[name] 40 | 41 | def getArgInt(self, name): 42 | arg = self.getArgMixed(name) 43 | assert isinstance(arg, six.integer_types), \ 44 | "%s: %s must be an int." % (self.__class__, name) 45 | return arg 46 | 47 | def getArgFloat(self, name): 48 | arg = self.getArgMixed(name) 49 | assert isinstance(arg, (six.integer_types, float)), \ 50 | "%s: %s must be a number." % (self.__class__, name) 51 | return float(arg) 52 | 53 | def getArgString(self, name): 54 | arg = self.getArgMixed(name) 55 | assert isinstance(arg, six.string_types), \ 56 | "%s: %s must be a string." % (self.__class__, name) 57 | return arg 58 | 59 | def getArgNumeric(self, name): 60 | arg = self.getArgMixed(name) 61 | assert isinstance(arg, (six.integer_types, float)), \ 62 | "%s: %s must be a numeric." % (self.__class__, name) 63 | return arg 64 | 65 | def getArgList(self, name): 66 | arg = self.getArgMixed(name) 67 | assert isinstance(arg, (list, tuple)), \ 68 | "%s: %s must be a list." % (self.__class__, name) 69 | return arg 70 | 71 | def getArgMap(self, name): 72 | arg = self.getArgMixed(name) 73 | assert isinstance(arg, dict), \ 74 | "%s: %s must be a map." % (self.__class__, name) 75 | return arg 76 | 77 | def getArgIndexish(self, name): 78 | arg = self.getArgMixed(name) 79 | assert isinstance(arg, (dict, list, tuple)), \ 80 | "%s: %s must be a map or list." % (self.__class__, name) 81 | return arg 82 | 83 | # PlanOutOpSimple is the easiest way to implement simple operators. 84 | # The class automatically evaluates the values of all args passed in via 85 | # execute(), and stores the PlanOut mapper object and evaluated 86 | # args as instance variables. The user can then extend PlanOutOpSimple 87 | # and implement simpleExecute(). 88 | 89 | class PlanOutOpSimple(PlanOutOp): 90 | __metaclass__ = ABCMeta 91 | 92 | def execute(self, mapper): 93 | self.mapper = mapper 94 | parameter_names = self.args.keys() 95 | for param in parameter_names: 96 | self.args[param] = mapper.evaluate(self.args[param]) 97 | return self.simpleExecute() 98 | 99 | 100 | class PlanOutOpBinary(PlanOutOpSimple): 101 | __metaclass__ = ABCMeta 102 | 103 | def simpleExecute(self): 104 | return self.binaryExecute( 105 | self.getArgMixed('left'), 106 | self.getArgMixed('right')) 107 | 108 | def pretty(self): 109 | return '%s %s %s' % ( 110 | Operators.pretty(self.args['left']), 111 | self.getInfixString(), 112 | Operators.pretty(self.args['right'])) 113 | 114 | def getInfixString(self): 115 | return self.args['op'] 116 | 117 | @abstractmethod 118 | def binaryExecute(self, left, right): 119 | pass 120 | 121 | 122 | class PlanOutOpUnary(PlanOutOpSimple): 123 | __metaclass__ = ABCMeta 124 | 125 | def simpleExecute(self): 126 | return self.unaryExecute(self.getArgMixed('value')) 127 | 128 | def pretty(self): 129 | return self.getUnaryString + Operators.pretty(self.getArgMixed('value')) 130 | 131 | def getUnaryString(self): 132 | return self.args['op'] 133 | 134 | @abstractmethod 135 | def unaryExecute(self, value): 136 | pass 137 | 138 | 139 | class PlanOutOpCommutative(PlanOutOpSimple): 140 | __metaclass__ = ABCMeta 141 | 142 | def simpleExecute(self): 143 | assert ('values' in self.args), "expected argument 'values'" 144 | return self.commutativeExecute(self.getArgList('values')) 145 | 146 | def pretty(self): 147 | values = Operators.strip_array(self.getArgList('values')) 148 | if type(values) is list: 149 | pretty_values = ', '.join([Operators.pretty(i) for i in values]) 150 | else: 151 | pretty_values = Operators.pretty(values) 152 | 153 | return '%s(%s)' % (self.getCommutativeString(), pretty_values) 154 | 155 | def getCommutativeString(self): 156 | return self.args['op'] 157 | 158 | @abstractmethod 159 | def commutativeExecute(self, values): 160 | pass 161 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PlanOut 2 | 3 | [![Build Status](https://img.shields.io/pypi/v/planout.svg)](https://pypi.org/project/planout/) 4 | [![Build Status](https://img.shields.io/pypi/pyversions/planout.svg)](https://pypi.org/project/planout/) 5 | [![Build Status](https://travis-ci.com/facebook/planout.svg?branch=master)](https://travis-ci.com/facebook/planout) 6 | 7 | PlanOut is a multi-platform framework and programming language for online field experimentation. PlanOut was created to make it easy to run and iterate on sophisticated experiments, while satisfying the constraints of deployed Internet services with many users. 8 | 9 | Developers integrate PlanOut by defining experiments that detail how _units_ (e.g., users, cookie IDs) should get mapped onto conditions. For example, to create a 2x2 experiment randomizing both the color and the text on a button, you create a class like this in Python: 10 | 11 | ```python 12 | class MyExperiment(SimpleExperiment): 13 | def assign(self, params, userid): 14 | params.button_color = UniformChoice(choices=['#ff0000', '#00ff00'], unit=userid) 15 | params.button_text = UniformChoice(choices=['I voted', 'I am a voter'], unit=userid) 16 | ``` 17 | 18 | Then, in the application code, you query the Experiment object to find out what values the current user should be mapped onto: 19 | ```python 20 | my_exp = MyExperiment(userid=101) 21 | color = my_exp.get('button_color') 22 | text = my_exp.get('button_text') 23 | ``` 24 | 25 | PlanOut takes care of randomizing each ``userid`` into the right bucket. It does so by hashing the input, so each ``userid`` will always map onto the same values for that experiment. 26 | 27 | ### What does the PlanOut distribution include? 28 | 29 | The reference implementation for PlanOut is written in Python. It includes: 30 | * Extensible classes for defining experiments. These classes make it easy to implement reliable, deterministic random assignment procedures, and automatically log key data. 31 | * A basic implementation of namespaces, which can be used to manage multiple mutually exclusive experiments. 32 | * The PlanOut interpreter, which executes serialized code generated by the PlanOut domain specific language. 33 | * An interactive Web-based editor and compiler for developing and testing 34 | PlanOut-language scripts. 35 | 36 | Other production-ready versions of PlanOut are available for Java, JavaScript, and PHP, and can found in the `java/`, `js/`, and `php/` directories, respectively. 37 | 38 | The `alpha/` directory contains implementations of PlanOut to other languages that are currently under development, including Go, Julia, and Ruby. 39 | 40 | ### Who is PlanOut for? 41 | PlanOut designed for researchers, students, and small businesses wanting to run experiments. It is built to be extensible, so that it may be adapted for use with large production environments. The implementation here mirrors many of the key components of Facebook's Hack-based implementation of PlanOut which is used to conduct experiments with hundreds of millions of users. 42 | 43 | ### Full Example 44 | 45 | To create a basic PlanOut experiment in Python, you subclass ``SimpleExperiment`` object, and implement an assignment method. You can use PlanOut's random assignment operators by setting ``e.varname``, where ``params`` is the first argument passed to the ``assign()`` method, and ``varname`` is the name of the variable you are setting. 46 | ```python 47 | 48 | from planout.experiment import SimpleExperiment 49 | from planout.ops.random import * 50 | 51 | class FirstExperiment(SimpleExperiment): 52 | def assign(self, params, userid): 53 | params.button_color = UniformChoice(choices=['#ff0000', '#00ff00'], unit=userid) 54 | params.button_text = WeightedChoice( 55 | choices=['Join now!', 'Sign up.'], 56 | weights=[0.3, 0.7], unit=userid) 57 | 58 | my_exp = FirstExperiment(userid=12) 59 | # parameters may be accessed via the . operator 60 | print my_exp.get('button_text'), my_exp.get('button_color') 61 | 62 | # experiment objects include all input data 63 | for i in xrange(6): 64 | print FirstExperiment(userid=i) 65 | ``` 66 | 67 | which outputs: 68 | ``` 69 | Join now! #ff0000 70 | {'inputs': {'userid': 0}, 'checksum': '22c13b16', 'salt': 'FirstExperiment', 'name': 'FirstExperiment', 'params': {'button_color': '#ff0000', 'button_text': 'Sign up.'}} 71 | {'inputs': {'userid': 1}, 'checksum': '22c13b16', 'salt': 'FirstExperiment', 'name': 'FirstExperiment', 'params': {'button_color': '#ff0000', 'button_text': 'Sign up.'}} 72 | {'inputs': {'userid': 2}, 'checksum': '22c13b16', 'salt': 'FirstExperiment', 'name': 'FirstExperiment', 'params': {'button_color': '#00ff00', 'button_text': 'Sign up.'}} 73 | {'inputs': {'userid': 3}, 'checksum': '22c13b16', 'salt': 'FirstExperiment', 'name': 'FirstExperiment', 'params': {'button_color': '#ff0000', 'button_text': 'Sign up.'}} 74 | {'inputs': {'userid': 4}, 'checksum': '22c13b16', 'salt': 'FirstExperiment', 'name': 'FirstExperiment', 'params': {'button_color': '#00ff00', 'button_text': 'Join now!'}} 75 | {'inputs': {'userid': 5}, 'checksum': '22c13b16', 'salt': 'FirstExperiment', 'name': 'FirstExperiment', 'params': {'button_color': '#00ff00', 'button_text': 'Sign up.'}} 76 | ``` 77 | 78 | The ``SimpleExperiment`` class will automatically concatenate the name of the experiment, ``FirstExperiment``, the variable name, and the input data (``userid``) and hash that string to perform the random assignment. Parameter assignments and inputs are automatically logged into a file called ``firstexperiment.log'``. 79 | 80 | 81 | ### Installation 82 | You can immediately install the reference implementation of PlanOut for Python using `pip` with: 83 | ``` 84 | pip install planout 85 | ``` 86 | 87 | See the `java/`, `php/`, `js/`, `alpha/golang`, `alpha/ruby`, `alpha/julia` directories for instructions on installing PlanOut for other languages. 88 | 89 | ### Learn more 90 | Learn more about PlanOut visiting the [PlanOut website](http://facebook.github.io/planout/) or by [reading the PlanOut paper](http://arxiv.org/pdf/1409.3174v1.pdf). You can cite PlanOut as "Designing and Deploying Online Field Experiments". Eytan Bakshy, Dean Eckles, Michael S. Bernstein. Proceedings of the 23rd ACM conference on the World Wide Web. April 7–11, 2014, Seoul, Korea, or by copying and pasting the bibtex below: 91 | ``` bibtex 92 | @inproceedings{bakshy2014www, 93 | Author = {Bakshy, E. and Eckles, D. and Bernstein, M.S.}, 94 | Booktitle = {Proceedings of the 23rd ACM conference on the World Wide Web}, 95 | Organization = {ACM}, 96 | Title = {Designing and Deploying Online Field Experiments}, 97 | Year = {2014} 98 | } 99 | ``` 100 | -------------------------------------------------------------------------------- /planout-editor/static/js/mode/planout/test.js: -------------------------------------------------------------------------------- 1 | // CodeMirror, copyright (c) by Marijn Haverbeke and others 2 | // Distributed under an MIT license: http://codemirror.net/LICENSE 3 | 4 | (function() { 5 | var mode = CodeMirror.getMode({indentUnit: 2}, "javascript"); 6 | function MT(name) { test.mode(name, mode, Array.prototype.slice.call(arguments, 1)); } 7 | 8 | MT("locals", 9 | "[keyword function] [variable foo]([def a], [def b]) { [keyword var] [def c] [operator =] [number 10]; [keyword return] [variable-2 a] [operator +] [variable-2 c] [operator +] [variable d]; }"); 10 | 11 | MT("comma-and-binop", 12 | "[keyword function](){ [keyword var] [def x] [operator =] [number 1] [operator +] [number 2], [def y]; }"); 13 | 14 | MT("destructuring", 15 | "([keyword function]([def a], [[[def b], [def c] ]]) {", 16 | " [keyword let] {[def d], [property foo]: [def c][operator =][number 10], [def x]} [operator =] [variable foo]([variable-2 a]);", 17 | " [[[variable-2 c], [variable y] ]] [operator =] [variable-2 c];", 18 | "})();"); 19 | 20 | MT("class_body", 21 | "[keyword class] [variable Foo] {", 22 | " [property constructor]() {}", 23 | " [property sayName]() {", 24 | " [keyword return] [string-2 `foo${][variable foo][string-2 }oo`];", 25 | " }", 26 | "}"); 27 | 28 | MT("class", 29 | "[keyword class] [variable Point] [keyword extends] [variable SuperThing] {", 30 | " [property get] [property prop]() { [keyword return] [number 24]; }", 31 | " [property constructor]([def x], [def y]) {", 32 | " [keyword super]([string 'something']);", 33 | " [keyword this].[property x] [operator =] [variable-2 x];", 34 | " }", 35 | "}"); 36 | 37 | MT("module", 38 | "[keyword module] [string 'foo'] {", 39 | " [keyword export] [keyword let] [def x] [operator =] [number 42];", 40 | " [keyword export] [keyword *] [keyword from] [string 'somewhere'];", 41 | "}"); 42 | 43 | MT("import", 44 | "[keyword function] [variable foo]() {", 45 | " [keyword import] [def $] [keyword from] [string 'jquery'];", 46 | " [keyword module] [def crypto] [keyword from] [string 'crypto'];", 47 | " [keyword import] { [def encrypt], [def decrypt] } [keyword from] [string 'crypto'];", 48 | "}"); 49 | 50 | MT("const", 51 | "[keyword function] [variable f]() {", 52 | " [keyword const] [[ [def a], [def b] ]] [operator =] [[ [number 1], [number 2] ]];", 53 | "}"); 54 | 55 | MT("for/of", 56 | "[keyword for]([keyword let] [variable of] [keyword of] [variable something]) {}"); 57 | 58 | MT("generator", 59 | "[keyword function*] [variable repeat]([def n]) {", 60 | " [keyword for]([keyword var] [def i] [operator =] [number 0]; [variable-2 i] [operator <] [variable-2 n]; [operator ++][variable-2 i])", 61 | " [keyword yield] [variable-2 i];", 62 | "}"); 63 | 64 | MT("fatArrow", 65 | "[variable array].[property filter]([def a] [operator =>] [variable-2 a] [operator +] [number 1]);", 66 | "[variable a];", // No longer in scope 67 | "[keyword let] [variable f] [operator =] ([[ [def a], [def b] ]], [def c]) [operator =>] [variable-2 a] [operator +] [variable-2 c];", 68 | "[variable c];"); 69 | 70 | MT("spread", 71 | "[keyword function] [variable f]([def a], [meta ...][def b]) {", 72 | " [variable something]([variable-2 a], [meta ...][variable-2 b]);", 73 | "}"); 74 | 75 | MT("comprehension", 76 | "[keyword function] [variable f]() {", 77 | " [[([variable x] [operator +] [number 1]) [keyword for] ([keyword var] [def x] [keyword in] [variable y]) [keyword if] [variable pred]([variable-2 x]) ]];", 78 | " ([variable u] [keyword for] ([keyword var] [def u] [keyword of] [variable generateValues]()) [keyword if] ([variable-2 u].[property color] [operator ===] [string 'blue']));", 79 | "}"); 80 | 81 | MT("quasi", 82 | "[variable re][string-2 `fofdlakj${][variable x] [operator +] ([variable re][string-2 `foo`]) [operator +] [number 1][string-2 }fdsa`] [operator +] [number 2]"); 83 | 84 | MT("quasi_no_function", 85 | "[variable x] [operator =] [string-2 `fofdlakj${][variable x] [operator +] [string-2 `foo`] [operator +] [number 1][string-2 }fdsa`] [operator +] [number 2]"); 86 | 87 | MT("indent_statement", 88 | "[keyword var] [variable x] [operator =] [number 10]", 89 | "[variable x] [operator +=] [variable y] [operator +]", 90 | " [atom Infinity]", 91 | "[keyword debugger];"); 92 | 93 | MT("indent_if", 94 | "[keyword if] ([number 1])", 95 | " [keyword break];", 96 | "[keyword else] [keyword if] ([number 2])", 97 | " [keyword continue];", 98 | "[keyword else]", 99 | " [number 10];", 100 | "[keyword if] ([number 1]) {", 101 | " [keyword break];", 102 | "} [keyword else] [keyword if] ([number 2]) {", 103 | " [keyword continue];", 104 | "} [keyword else] {", 105 | " [number 10];", 106 | "}"); 107 | 108 | MT("indent_for", 109 | "[keyword for] ([keyword var] [variable i] [operator =] [number 0];", 110 | " [variable i] [operator <] [number 100];", 111 | " [variable i][operator ++])", 112 | " [variable doSomething]([variable i]);", 113 | "[keyword debugger];"); 114 | 115 | MT("indent_c_style", 116 | "[keyword function] [variable foo]()", 117 | "{", 118 | " [keyword debugger];", 119 | "}"); 120 | 121 | MT("indent_else", 122 | "[keyword for] (;;)", 123 | " [keyword if] ([variable foo])", 124 | " [keyword if] ([variable bar])", 125 | " [number 1];", 126 | " [keyword else]", 127 | " [number 2];", 128 | " [keyword else]", 129 | " [number 3];"); 130 | 131 | MT("indent_funarg", 132 | "[variable foo]([number 10000],", 133 | " [keyword function]([def a]) {", 134 | " [keyword debugger];", 135 | "};"); 136 | 137 | MT("indent_below_if", 138 | "[keyword for] (;;)", 139 | " [keyword if] ([variable foo])", 140 | " [number 1];", 141 | "[number 2];"); 142 | 143 | MT("multilinestring", 144 | "[keyword var] [variable x] [operator =] [string 'foo\\]", 145 | "[string bar'];"); 146 | 147 | MT("scary_regexp", 148 | "[string-2 /foo[[/]]bar/];"); 149 | 150 | var jsonld_mode = CodeMirror.getMode( 151 | {indentUnit: 2}, 152 | {name: "javascript", jsonld: true} 153 | ); 154 | function LD(name) { 155 | test.mode(name, jsonld_mode, Array.prototype.slice.call(arguments, 1)); 156 | } 157 | 158 | LD("json_ld_keywords", 159 | '{', 160 | ' [meta "@context"]: {', 161 | ' [meta "@base"]: [string "http://example.com"],', 162 | ' [meta "@vocab"]: [string "http://xmlns.com/foaf/0.1/"],', 163 | ' [property "likesFlavor"]: {', 164 | ' [meta "@container"]: [meta "@list"]', 165 | ' [meta "@reverse"]: [string "@beFavoriteOf"]', 166 | ' },', 167 | ' [property "nick"]: { [meta "@container"]: [meta "@set"] },', 168 | ' [property "nick"]: { [meta "@container"]: [meta "@index"] }', 169 | ' },', 170 | ' [meta "@graph"]: [[ {', 171 | ' [meta "@id"]: [string "http://dbpedia.org/resource/John_Lennon"],', 172 | ' [property "name"]: [string "John Lennon"],', 173 | ' [property "modified"]: {', 174 | ' [meta "@value"]: [string "2010-05-29T14:17:39+02:00"],', 175 | ' [meta "@type"]: [string "http://www.w3.org/2001/XMLSchema#dateTime"]', 176 | ' }', 177 | ' } ]]', 178 | '}'); 179 | 180 | LD("json_ld_fake", 181 | '{', 182 | ' [property "@fake"]: [string "@fake"],', 183 | ' [property "@contextual"]: [string "@identifier"],', 184 | ' [property "user@domain.com"]: [string "@graphical"],', 185 | ' [property "@ID"]: [string "@@ID"]', 186 | '}'); 187 | })(); 188 | -------------------------------------------------------------------------------- /planout-editor/static/js/mode/javascript/test.js: -------------------------------------------------------------------------------- 1 | // CodeMirror, copyright (c) by Marijn Haverbeke and others 2 | // Distributed under an MIT license: http://codemirror.net/LICENSE 3 | 4 | (function() { 5 | var mode = CodeMirror.getMode({indentUnit: 2}, "javascript"); 6 | function MT(name) { test.mode(name, mode, Array.prototype.slice.call(arguments, 1)); } 7 | 8 | MT("locals", 9 | "[keyword function] [variable foo]([def a], [def b]) { [keyword var] [def c] [operator =] [number 10]; [keyword return] [variable-2 a] [operator +] [variable-2 c] [operator +] [variable d]; }"); 10 | 11 | MT("comma-and-binop", 12 | "[keyword function](){ [keyword var] [def x] [operator =] [number 1] [operator +] [number 2], [def y]; }"); 13 | 14 | MT("destructuring", 15 | "([keyword function]([def a], [[[def b], [def c] ]]) {", 16 | " [keyword let] {[def d], [property foo]: [def c][operator =][number 10], [def x]} [operator =] [variable foo]([variable-2 a]);", 17 | " [[[variable-2 c], [variable y] ]] [operator =] [variable-2 c];", 18 | "})();"); 19 | 20 | MT("class_body", 21 | "[keyword class] [variable Foo] {", 22 | " [property constructor]() {}", 23 | " [property sayName]() {", 24 | " [keyword return] [string-2 `foo${][variable foo][string-2 }oo`];", 25 | " }", 26 | "}"); 27 | 28 | MT("class", 29 | "[keyword class] [variable Point] [keyword extends] [variable SuperThing] {", 30 | " [property get] [property prop]() { [keyword return] [number 24]; }", 31 | " [property constructor]([def x], [def y]) {", 32 | " [keyword super]([string 'something']);", 33 | " [keyword this].[property x] [operator =] [variable-2 x];", 34 | " }", 35 | "}"); 36 | 37 | MT("module", 38 | "[keyword module] [string 'foo'] {", 39 | " [keyword export] [keyword let] [def x] [operator =] [number 42];", 40 | " [keyword export] [keyword *] [keyword from] [string 'somewhere'];", 41 | "}"); 42 | 43 | MT("import", 44 | "[keyword function] [variable foo]() {", 45 | " [keyword import] [def $] [keyword from] [string 'jquery'];", 46 | " [keyword module] [def crypto] [keyword from] [string 'crypto'];", 47 | " [keyword import] { [def encrypt], [def decrypt] } [keyword from] [string 'crypto'];", 48 | "}"); 49 | 50 | MT("const", 51 | "[keyword function] [variable f]() {", 52 | " [keyword const] [[ [def a], [def b] ]] [operator =] [[ [number 1], [number 2] ]];", 53 | "}"); 54 | 55 | MT("for/of", 56 | "[keyword for]([keyword let] [variable of] [keyword of] [variable something]) {}"); 57 | 58 | MT("generator", 59 | "[keyword function*] [variable repeat]([def n]) {", 60 | " [keyword for]([keyword var] [def i] [operator =] [number 0]; [variable-2 i] [operator <] [variable-2 n]; [operator ++][variable-2 i])", 61 | " [keyword yield] [variable-2 i];", 62 | "}"); 63 | 64 | MT("fatArrow", 65 | "[variable array].[property filter]([def a] [operator =>] [variable-2 a] [operator +] [number 1]);", 66 | "[variable a];", // No longer in scope 67 | "[keyword let] [variable f] [operator =] ([[ [def a], [def b] ]], [def c]) [operator =>] [variable-2 a] [operator +] [variable-2 c];", 68 | "[variable c];"); 69 | 70 | MT("spread", 71 | "[keyword function] [variable f]([def a], [meta ...][def b]) {", 72 | " [variable something]([variable-2 a], [meta ...][variable-2 b]);", 73 | "}"); 74 | 75 | MT("comprehension", 76 | "[keyword function] [variable f]() {", 77 | " [[([variable x] [operator +] [number 1]) [keyword for] ([keyword var] [def x] [keyword in] [variable y]) [keyword if] [variable pred]([variable-2 x]) ]];", 78 | " ([variable u] [keyword for] ([keyword var] [def u] [keyword of] [variable generateValues]()) [keyword if] ([variable-2 u].[property color] [operator ===] [string 'blue']));", 79 | "}"); 80 | 81 | MT("quasi", 82 | "[variable re][string-2 `fofdlakj${][variable x] [operator +] ([variable re][string-2 `foo`]) [operator +] [number 1][string-2 }fdsa`] [operator +] [number 2]"); 83 | 84 | MT("quasi_no_function", 85 | "[variable x] [operator =] [string-2 `fofdlakj${][variable x] [operator +] [string-2 `foo`] [operator +] [number 1][string-2 }fdsa`] [operator +] [number 2]"); 86 | 87 | MT("indent_statement", 88 | "[keyword var] [variable x] [operator =] [number 10]", 89 | "[variable x] [operator +=] [variable y] [operator +]", 90 | " [atom Infinity]", 91 | "[keyword debugger];"); 92 | 93 | MT("indent_if", 94 | "[keyword if] ([number 1])", 95 | " [keyword break];", 96 | "[keyword else] [keyword if] ([number 2])", 97 | " [keyword continue];", 98 | "[keyword else]", 99 | " [number 10];", 100 | "[keyword if] ([number 1]) {", 101 | " [keyword break];", 102 | "} [keyword else] [keyword if] ([number 2]) {", 103 | " [keyword continue];", 104 | "} [keyword else] {", 105 | " [number 10];", 106 | "}"); 107 | 108 | MT("indent_for", 109 | "[keyword for] ([keyword var] [variable i] [operator =] [number 0];", 110 | " [variable i] [operator <] [number 100];", 111 | " [variable i][operator ++])", 112 | " [variable doSomething]([variable i]);", 113 | "[keyword debugger];"); 114 | 115 | MT("indent_c_style", 116 | "[keyword function] [variable foo]()", 117 | "{", 118 | " [keyword debugger];", 119 | "}"); 120 | 121 | MT("indent_else", 122 | "[keyword for] (;;)", 123 | " [keyword if] ([variable foo])", 124 | " [keyword if] ([variable bar])", 125 | " [number 1];", 126 | " [keyword else]", 127 | " [number 2];", 128 | " [keyword else]", 129 | " [number 3];"); 130 | 131 | MT("indent_funarg", 132 | "[variable foo]([number 10000],", 133 | " [keyword function]([def a]) {", 134 | " [keyword debugger];", 135 | "};"); 136 | 137 | MT("indent_below_if", 138 | "[keyword for] (;;)", 139 | " [keyword if] ([variable foo])", 140 | " [number 1];", 141 | "[number 2];"); 142 | 143 | MT("multilinestring", 144 | "[keyword var] [variable x] [operator =] [string 'foo\\]", 145 | "[string bar'];"); 146 | 147 | MT("scary_regexp", 148 | "[string-2 /foo[[/]]bar/];"); 149 | 150 | var jsonld_mode = CodeMirror.getMode( 151 | {indentUnit: 2}, 152 | {name: "javascript", jsonld: true} 153 | ); 154 | function LD(name) { 155 | test.mode(name, jsonld_mode, Array.prototype.slice.call(arguments, 1)); 156 | } 157 | 158 | LD("json_ld_keywords", 159 | '{', 160 | ' [meta "@context"]: {', 161 | ' [meta "@base"]: [string "http://example.com"],', 162 | ' [meta "@vocab"]: [string "http://xmlns.com/foaf/0.1/"],', 163 | ' [property "likesFlavor"]: {', 164 | ' [meta "@container"]: [meta "@list"]', 165 | ' [meta "@reverse"]: [string "@beFavoriteOf"]', 166 | ' },', 167 | ' [property "nick"]: { [meta "@container"]: [meta "@set"] },', 168 | ' [property "nick"]: { [meta "@container"]: [meta "@index"] }', 169 | ' },', 170 | ' [meta "@graph"]: [[ {', 171 | ' [meta "@id"]: [string "http://dbpedia.org/resource/John_Lennon"],', 172 | ' [property "name"]: [string "John Lennon"],', 173 | ' [property "modified"]: {', 174 | ' [meta "@value"]: [string "2010-05-29T14:17:39+02:00"],', 175 | ' [meta "@type"]: [string "http://www.w3.org/2001/XMLSchema#dateTime"]', 176 | ' }', 177 | ' } ]]', 178 | '}'); 179 | 180 | LD("json_ld_fake", 181 | '{', 182 | ' [property "@fake"]: [string "@fake"],', 183 | ' [property "@contextual"]: [string "@identifier"],', 184 | ' [property "user@domain.com"]: [string "@graphical"],', 185 | ' [property "@ID"]: [string "@@ID"]', 186 | '}'); 187 | })(); 188 | -------------------------------------------------------------------------------- /planout-editor/js/stores/PlanOutTesterStore.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2013-2014 Facebook, Inc. 3 | * 4 | * PlanOutTesterStore 5 | * @jsx 6 | */ 7 | 8 | var _ = require('underscore'); 9 | var EventEmitter = require('events').EventEmitter; 10 | var assign = require('object-assign'); 11 | 12 | var PlanOutAsyncRequests = require('../utils/PlanOutAsyncRequests'); 13 | var PlanOutEditorDispatcher = require('../dispatcher/PlanOutEditorDispatcher'); 14 | var PlanOutExperimentStore = require('../stores/PlanOutExperimentStore'); 15 | 16 | var PlanOutEditorConstants = require('../constants/PlanOutEditorConstants'); 17 | var ActionTypes = PlanOutEditorConstants.ActionTypes; 18 | var TesterStatusCodes = PlanOutEditorConstants.TesterStatusCodes; 19 | var TesterBoxTypes = PlanOutEditorConstants.TesterBoxTypes; 20 | 21 | 22 | var CHANGE_EVENT = 'change'; 23 | var DEFAULT_INPUTS = {'userid': 4}; // default when units cannot be inferred 24 | 25 | 26 | var _tests = {}; // indexed set of tests 27 | var _test_ids = []; // ids giving the order of tests 28 | 29 | 30 | function _createTestBox(testType) { 31 | PlanOutEditorDispatcher.waitFor([PlanOutExperimentStore.dispatchToken]); 32 | if (PlanOutExperimentStore.doesCompile()) { 33 | 34 | // generate input blob with reasonable random defaults based on which 35 | // variables are used but not set by the PlanOut script 36 | var inferred_input_variables = PlanOutExperimentStore.getInputVariables(); 37 | 38 | var input_dict = {}; 39 | /* 40 | if (inferred_input_variables.length > 0) { 41 | for(var i = 0; i < inferred_input_variables.length; i++) { 42 | input_dict[inferred_input_variables[i]] = 43 | Math.floor((Math.random() * 1000)); 44 | } 45 | } else { 46 | input_dict = _.clone(DEFAULT_INPUTS); 47 | } 48 | */ 49 | 50 | // note that action.assertions may be undefined when you have a playground 51 | var id = 'test-' + Date.now(); 52 | _tests[id] = { 53 | id: id, 54 | inputs: input_dict, 55 | overrides: undefined, 56 | assertions: {}, 57 | status: TesterStatusCodes.PENDING, 58 | errors: undefined, 59 | results: undefined, 60 | type: testType, 61 | run: PlanOutAsyncRequests.genRunner(id) 62 | }; 63 | _test_ids.push(id); 64 | _refreshTest(id); 65 | } 66 | } 67 | 68 | function _createPlayground() { 69 | _createTestBox(TesterBoxTypes.PLAYGROUND); 70 | } 71 | 72 | function _createTest() { 73 | _createTestBox(TesterBoxTypes.TEST); 74 | } 75 | 76 | /** 77 | * Update a TESTER item. 78 | * @param {string} id 79 | * @param {object} updates An object literal containing only the data to be 80 | * updated. 81 | */ 82 | function _update(/*string*/ id, /*object*/ updates) { 83 | _tests[id] = assign({}, _tests[id], updates); 84 | } 85 | 86 | /** 87 | * Delete a TEST item. 88 | * @param {string} id 89 | */ 90 | function _destroy(/*string*/ id) { 91 | _test_ids = _test_ids.filter(function(x) {return x !== id;}); 92 | delete _tests[id]; 93 | } 94 | 95 | function _getScrubbedInputs(/*string*/ id) { 96 | var input_variables = PlanOutExperimentStore.getInputVariables(); 97 | var inputs = _tests[id].inputs || {}; 98 | for (var key in inputs) { 99 | if (inputs[key] === null) { 100 | delete inputs[key]; 101 | } 102 | } 103 | input_variables.forEach(function(key) { 104 | if (!inputs.hasOwnProperty(key)) { 105 | inputs[key] = null; 106 | } 107 | }); 108 | return inputs; 109 | } 110 | 111 | function _refreshTest(/*string*/ id) { 112 | if (PlanOutExperimentStore.doesCompile()) { 113 | var request = assign({}, 114 | _tests[id], 115 | {compiled_code: PlanOutExperimentStore.getJSON()} 116 | ); 117 | _tests[id].inputs = _getScrubbedInputs(id); 118 | _tests[id].run(request); 119 | } 120 | } 121 | 122 | function _refreshAllTests() { 123 | for (var id in _tests) { 124 | _refreshTest(id); 125 | } 126 | } 127 | 128 | function _getTestArray() /*array*/ { 129 | return _test_ids.map(function(id) {return _tests[id];}); 130 | } 131 | 132 | var PlanOutTesterStore = assign({}, EventEmitter.prototype, { 133 | 134 | getAllTests: function() /*array*/ { 135 | return _getTestArray(); 136 | }, 137 | 138 | getSerializedTests: function() /*array*/ { 139 | return _getTestArray().map( 140 | function(x) { 141 | return _.pick(x, ['id', 'inputs', 'overrides', 'assertions', 'type']); 142 | } 143 | ); 144 | }, 145 | 146 | /** 147 | * Tests whether all the remaining TESTER items are marked as completed. 148 | * @return {booleam} 149 | */ 150 | areAllPassing: function() /*bool*/ { 151 | for (var id in _tests) { 152 | if (_tests[id].status !== TesterStatusCodes.SUCCESS) { 153 | return false; 154 | } 155 | } 156 | return true; 157 | }, 158 | 159 | emitChange: function() { 160 | this.emit(CHANGE_EVENT); 161 | }, 162 | 163 | /** 164 | * @param {function} callback 165 | */ 166 | addChangeListener: function(/*function*/ callback) { 167 | this.addListener(CHANGE_EVENT, callback); 168 | }, 169 | 170 | /** 171 | * @param {function} callback 172 | */ 173 | removeChangeListener: function(/*function*/ callback) { 174 | this.removeListener(CHANGE_EVENT, callback); 175 | } 176 | }); 177 | 178 | // Register to handle all updates 179 | PlanOutTesterStore.dispatchToken = 180 | PlanOutEditorDispatcher.register(function(/*object*/ payload) { 181 | var action = payload.action; 182 | 183 | switch(action.actionType) { 184 | case ActionTypes.INIT_TESTER_PANEL: 185 | _createPlayground(); 186 | break; 187 | 188 | case ActionTypes.TESTER_CREATE: 189 | _createTest(); 190 | break; 191 | 192 | /** 193 | * Add multiple existing tests into TestStore. 194 | * @param {test} object containing tester data 195 | */ 196 | case ActionTypes.LOAD_SERIALIZED_TESTERS: 197 | _tests = {}; 198 | _test_ids = []; 199 | for (var i = 0; i < action.tests.length; i++) { 200 | var test = action.tests[i]; 201 | var id = test.id; 202 | _tests[id] = { 203 | id: test.id, 204 | inputs: test.inputs, 205 | overrides: test.overrides, 206 | assertions: test.assertions, 207 | status: TesterStatusCodes.PENDING, 208 | type: test.type, 209 | run: PlanOutAsyncRequests.genRunner(id) 210 | }; 211 | _test_ids.push(id); 212 | } 213 | break; 214 | 215 | case ActionTypes.TESTER_USER_UPDATE_TEST: 216 | _update(action.id, action.fieldsToUpdate); 217 | PlanOutEditorDispatcher.waitFor([PlanOutExperimentStore.dispatchToken]); 218 | _refreshTest(action.id); 219 | break; 220 | 221 | // callback from server 222 | case ActionTypes.TESTER_SERVER_UPDATE_TEST: 223 | _update(action.id, { 224 | status: (action.errors && action.errors.length > 0) ? 225 | TesterStatusCodes.FAILURE : TesterStatusCodes.SUCCESS, 226 | errors: action.errors, 227 | results: action.results 228 | }); 229 | break; 230 | 231 | // User enters invalid input into form. Might be deprecated with better UI 232 | // components 233 | case ActionTypes.TESTER_INVALID_TEST_FORM: 234 | _update(action.id, { 235 | status: action.status 236 | }); 237 | break; 238 | 239 | case ActionTypes.TESTER_DESTROY: 240 | _destroy(action.id); 241 | break; 242 | 243 | case ActionTypes.EDITOR_UPDATE_COMPILED_CODE: 244 | case ActionTypes.TESTER_REFRESH_ALL_TESTS: 245 | PlanOutEditorDispatcher.waitFor([PlanOutExperimentStore.dispatchToken]); 246 | _refreshAllTests(); 247 | break; 248 | 249 | default: 250 | // no change needed to emit 251 | return true; 252 | } 253 | 254 | PlanOutTesterStore.emitChange(); 255 | return true; // No errors. Needed by promise in Dispatcher. 256 | }); 257 | 258 | 259 | module.exports = PlanOutTesterStore; 260 | -------------------------------------------------------------------------------- /compiler/planout.jison: -------------------------------------------------------------------------------- 1 | %lex 2 | 3 | %% 4 | 5 | "#"(.)*(\n|$) /* skip comments */ 6 | \s+ /* skip whitespace */ 7 | 8 | "true" return 'TRUE' 9 | "false" return 'FALSE' 10 | "null" return 'NULL' 11 | "@" return 'JSON_START' 12 | 13 | "switch" return 'SWITCH'; 14 | "if" return 'IF'; 15 | "else" return 'ELSE'; 16 | 17 | "return" return 'RETURN'; 18 | 19 | [a-zA-Z_][a-zA-Z0-9_]* return 'IDENTIFIER' 20 | 21 | [0-9]*\.?[0-9]+([eE][-+]?[0-9]+)? { yytext = Number(yytext); return 'CONST'; } 22 | \"(\\.|[^\\"])*\" { yytext = yytext.substr(1, yyleng-2); return 'CONST'; } 23 | \'[^\']*\' { yytext = yytext.substr(1, yyleng-2); return 'CONST'; } 24 | 25 | "<-" return 'ARROW_ASSIGN' 26 | "||" return 'OR' 27 | "&&" return 'AND' 28 | "??" return 'COALESCE' 29 | "==" return 'EQUALS' 30 | ">=" return 'GTE' 31 | "<=" return 'LTE' 32 | "!=" return 'NEQ' 33 | "=>" return 'THEN' 34 | ";" return 'END_STATEMENT' 35 | 36 | "="|":"|"["|"]"|"("|")"|","|"{"|"}"|"+"|"%"|"*"|"-"|"/"|"%"|">"|"<"|"!" 37 | return yytext 38 | 39 | /lex 40 | 41 | %token ARROW_ASSIGN 42 | %token AND 43 | %token CONST 44 | %token DEFAULT 45 | %token ELSE 46 | %token END_STATEMENT 47 | %token EQUALS 48 | %token GTE 49 | %token IDENTIFIER 50 | %token IF 51 | %token JSON_START 52 | %token LTE 53 | %token NEQ 54 | %token OR 55 | %token COALESCE 56 | %token SWITCH 57 | %token THEN 58 | %token RETURN 59 | 60 | %left '!' 61 | %left OR AND COALESCE 62 | %left EQUALS NEQ LTE GTE '>' '<' 63 | %left '+' '-' 64 | %left '*' '/' '%' 65 | %left '(' '!' 66 | %left '[' 67 | 68 | %% 69 | 70 | start 71 | : rules_list 72 | { $$ = {"op": "seq", "seq": $1}; return $$; } 73 | ; 74 | 75 | rules_list 76 | : /* empty */ 77 | { $$ = []; } 78 | | rules_list rule 79 | { $$ = $1; $$.push($2); } 80 | ; 81 | 82 | rule 83 | : expression 84 | { $$ = $1; } 85 | | expression END_STATEMENT 86 | { $$ = $1; } 87 | | IDENTIFIER '=' simple_expression END_STATEMENT 88 | { $$ = {"op": "set", "var": $1, "value": $3}; } 89 | | IDENTIFIER ARROW_ASSIGN simple_expression END_STATEMENT 90 | { $$ = {"op": "set", "var": $1, "value": $3}; } 91 | ; 92 | 93 | expression 94 | : switch_expression 95 | { $$ = $1; } 96 | | if_expression 97 | { $$ = $1; } 98 | | return_expression 99 | { $$ = $1; } 100 | ; 101 | 102 | simple_expression 103 | : IDENTIFIER 104 | { $$ = {"op": "get", "var": $1}; } 105 | | TRUE 106 | { $$ = true; } 107 | | FALSE 108 | { $$ = false; } 109 | | NULL 110 | { $$ = null; } 111 | | '[' array ']' 112 | { $$ = {"op": "array", "values": $2}; } 113 | | IDENTIFIER '(' arguments ')' 114 | { $$ = $3; $$["op"] = $1; } 115 | | simple_expression '[' simple_expression ']' 116 | { $$ = {"op": "index", "base": $1, "index": $3}; } 117 | | '{' rules_list '}' 118 | { $$ = {"op": "seq", "seq": $2}; } 119 | | '(' simple_expression ')' 120 | { $$ = $2; } 121 | | CONST 122 | { $$ = $1; } 123 | | JSON_START json { $$ = {"op": "literal", "value": $2}; } 124 | | simple_expression '%' simple_expression 125 | { $$ = {"op": "%", "left": $1, "right": $3}; } 126 | | simple_expression '/' simple_expression 127 | { $$ = {"op": "/", "left": $1, "right": $3}; } 128 | | simple_expression '>' simple_expression 129 | { $$ = {"op": ">", "left": $1, "right": $3}; } 130 | | simple_expression '<' simple_expression 131 | { $$ = {"op": "<", "left": $1, "right": $3}; } 132 | | simple_expression EQUALS simple_expression 133 | { $$ = {"op": "equals", "left": $1, "right": $3}; } 134 | | simple_expression NEQ simple_expression 135 | { $$ = {"op": "not", "value": {"op": "equals", "left": $1, "right": $3}}; } 136 | | simple_expression LTE simple_expression 137 | { $$ = {"op": "<=", "left": $1, "right": $3}; } 138 | | simple_expression GTE simple_expression 139 | { $$ = {"op": ">=", "left": $1, "right": $3}; } 140 | | simple_expression '+' simple_expression 141 | { $$ = {"op": "sum", "values": [$1, $3]}; } 142 | | simple_expression '-' simple_expression 143 | { $$ = {"op": "sum", "values": [$1, {"op": "negative", "value": $3}]}; } 144 | | simple_expression '*' simple_expression 145 | { $$ = {"op": "product", "values": [$1, $3]}; } 146 | | '-' simple_expression 147 | { $$ = {"op": "negative", "value": $2}; } 148 | | '!' simple_expression 149 | { $$ = {"op": "not", "value": $2}; } 150 | | simple_expression OR simple_expression 151 | { $$ = {"op": "or", "values": [$1, $3]}; } 152 | | simple_expression COALESCE simple_expression 153 | { $$ = {"op": "coalesce", "values": [$1, $3]}; } 154 | | simple_expression AND simple_expression 155 | { $$ = {"op": "and", "values": [$1, $3]}; } 156 | ; 157 | 158 | array 159 | : /* empty */ 160 | { $$ = []; } 161 | | simple_expression 162 | { $$ = [$1]; } 163 | | array ',' simple_expression 164 | { $$ = $1; $$.push($3); } 165 | ; 166 | 167 | json: /* true, false, null, etc. */ 168 | IDENTIFIER { $$ = JSON.parse($1); } 169 | | CONST { $$ = $1; } 170 | | TRUE { $$ = true; } 171 | | FALSE { $$ = false; } 172 | | NULL { $$ = null; } 173 | | '-' json_neg_num {$$ = $2; } 174 | | '[' json_array ']' { $$ = $2; } 175 | | '{' json_map '}' { $$ = $2; } 176 | ; 177 | 178 | json_array: /* empty */ { $$ = []; } 179 | | json { $$ = []; $$.push($1); } 180 | | json_array ',' json { $$ = $1; $$.push($3); } 181 | ; 182 | 183 | json_map: /* empty */ { $$ = {}; } 184 | | json ':' json { $$ = {}; $$[$1] = $3; } 185 | | json_map ',' json ':' json { $$ = $1; $$[$3] = $5; } 186 | ; 187 | 188 | json_neg_num: CONST { $$ = -$1; }; 189 | 190 | arguments 191 | : /* empty */ 192 | { $$ = {}; } 193 | | arguments_list 194 | { $$ = $1; } 195 | | values_list 196 | { if ($1["values"].length > 1) { 197 | $$ = $1; 198 | } else { 199 | $$ = {"value": $1["values"][0]}; 200 | } 201 | } 202 | ; 203 | 204 | arguments_list 205 | : IDENTIFIER '=' simple_expression 206 | { $$ = {}; $$[$1] = $3; } 207 | | arguments_list ',' IDENTIFIER '=' simple_expression 208 | { $$ = $1; $$[$3] = $5; } 209 | ; 210 | 211 | values_list 212 | : simple_expression 213 | { $$ = {}; $$["values"] = [$1]; } 214 | | values_list ',' simple_expression 215 | { $$ = $1; $$["values"].push($3); } 216 | ; 217 | 218 | switch_expression 219 | : SWITCH '{' cases_list '}' 220 | { $$ = {"op": "switch", "cases": $3}; } 221 | ; 222 | 223 | if_expression 224 | : IF '(' simple_expression ')' simple_expression optional_else_expression 225 | { $$ = {"op": "cond", "cond": [{"if": $3, "then": $5}]}; 226 | if ($6["cond"]) { 227 | $$["cond"] = $$["cond"].concat($6["cond"]); 228 | } 229 | } 230 | ; 231 | 232 | return_expression 233 | : RETURN simple_expression 234 | { $$ = {"op": "return", "value": $2} } 235 | ; 236 | 237 | optional_else_expression 238 | : /* empty */ 239 | { $$ = {}; } 240 | | ELSE if_expression 241 | { $$ = $2; } 242 | | ELSE simple_expression 243 | { $$ = {"op": "cond", "cond": [{"if": true, "then": $2}]}; } 244 | ; 245 | 246 | cases_list 247 | : /* empty */ 248 | { $$ = []; } 249 | | cases_list case END_STATEMENT 250 | { $$ = $1; $$.push($2); } 251 | ; 252 | 253 | case 254 | : simple_expression THEN expression 255 | { $$ = {"op": "case", "condidion": $1, "result": $3}; } 256 | ; 257 | -------------------------------------------------------------------------------- /python/planout/namespace.py: -------------------------------------------------------------------------------- 1 | import six 2 | from abc import ABCMeta, abstractmethod, abstractproperty 3 | from operator import itemgetter 4 | 5 | from .experiment import Experiment, DefaultExperiment 6 | from .ops.random import Sample, FastSample, RandomInteger 7 | from .assignment import Assignment 8 | 9 | # decorator for methods that assume assignments have been made 10 | 11 | 12 | def requires_experiment(f): 13 | def wrapped_f(self, *args, **kwargs): 14 | if not self._experiment: 15 | self._assign_experiment() 16 | return f(self, *args, **kwargs) 17 | return wrapped_f 18 | 19 | 20 | def requires_default_experiment(f): 21 | def wrapped_f(self, *args, **kwargs): 22 | if not self._default_experiment: 23 | self._assign_default_experiment() 24 | return f(self, *args, **kwargs) 25 | return wrapped_f 26 | 27 | 28 | class Namespace(object): 29 | __metaclass__ = ABCMeta 30 | 31 | def __init__(self, **kwargs): 32 | pass 33 | 34 | @abstractmethod 35 | def add_experiment(self, name, exp_object, num_segments, **kwargs): 36 | pass 37 | 38 | @abstractmethod 39 | def remove_experiment(self, name): 40 | pass 41 | 42 | @abstractmethod 43 | def set_auto_exposure_logging(self, value): 44 | pass 45 | 46 | @abstractproperty 47 | def in_experiment(self): 48 | pass 49 | 50 | @abstractmethod 51 | def get(self, name, default): 52 | pass 53 | 54 | @abstractmethod 55 | def log_exposure(self, extras=None): 56 | pass 57 | 58 | @abstractmethod 59 | def log_event(self, event_type, extras=None): 60 | pass 61 | 62 | 63 | class SimpleNamespace(Namespace): 64 | __metaclass__ = ABCMeta 65 | 66 | def __init__(self, **kwargs): 67 | self.name = self.__class__ # default name is the class name 68 | self.inputs = kwargs 69 | self.num_segments = None 70 | 71 | # dictionary mapping segments to experiment names 72 | self.segment_allocations = {} 73 | 74 | # dictionary mapping experiment names to experiment objects 75 | self.current_experiments = {} 76 | 77 | self._experiment = None # memoized experiment object 78 | self._default_experiment = None # memoized default experiment object 79 | self.default_experiment_class = DefaultExperiment 80 | self._in_experiment = False 81 | 82 | # setup name, primary key, number of segments, etc 83 | self.setup() 84 | self.available_segments = set(range(self.num_segments)) 85 | 86 | # load namespace with experiments 87 | self.setup_experiments() 88 | 89 | @abstractmethod 90 | def setup(self): 91 | """Sets up experiment""" 92 | # Developers extending this class should set the following variables 93 | # self.name = 'sample namespace' 94 | # self.primary_unit = 'userid' 95 | # self.num_segments = 10000 96 | pass 97 | 98 | @abstractmethod 99 | def setup_experiments(self): 100 | # e.g., 101 | # self.add_experiment('first experiment', Exp1, 100) 102 | pass 103 | 104 | @property 105 | def primary_unit(self): 106 | return self._primary_unit 107 | 108 | @primary_unit.setter 109 | def primary_unit(self, value): 110 | # later on we require that the primary key is a list, so we use 111 | # a setter to convert strings to a single element list 112 | if type(value) is list: 113 | self._primary_unit = value 114 | else: 115 | self._primary_unit = [value] 116 | 117 | def add_experiment(self, name, exp_object, segments): 118 | num_avail = len(self.available_segments) 119 | if num_avail < segments: 120 | print('error: %s segments requested, only %s available.' % 121 | (segments, num_avail)) 122 | return False 123 | if name in self.current_experiments: 124 | print('error: there is already an experiment called %s.' % name) 125 | return False 126 | 127 | # randomly select the given number of segments from all available 128 | # segments 129 | a = Assignment(self.name) 130 | if 'use_fast_sample' in self.inputs: 131 | a.sampled_segments = \ 132 | FastSample(choices=list(self.available_segments), 133 | draws=segments, unit=name) 134 | else: 135 | a.sampled_segments = \ 136 | Sample(choices=list(self.available_segments), 137 | draws=segments, unit=name) 138 | 139 | # assign each segment to the experiment name 140 | for segment in a.sampled_segments: 141 | self.segment_allocations[segment] = name 142 | self.available_segments.remove(segment) 143 | 144 | # associate the experiment name with an object 145 | self.current_experiments[name] = exp_object 146 | 147 | def remove_experiment(self, name): 148 | if name not in self.current_experiments: 149 | print('error: there is no experiment called %s.' % name) 150 | return False 151 | 152 | segments_to_free = \ 153 | [s for s, n in six.iteritems(self.segment_allocations) if n == name] 154 | 155 | for segment in segments_to_free: 156 | del self.segment_allocations[segment] 157 | self.available_segments.add(segment) 158 | del self.current_experiments[name] 159 | 160 | return True 161 | 162 | def get_segment(self): 163 | # randomly assign primary unit to a segment 164 | a = Assignment(self.name) 165 | a.segment = RandomInteger(min=0, max=self.num_segments - 1, 166 | unit=itemgetter(*self.primary_unit)(self.inputs)) 167 | return a.segment 168 | 169 | def _assign_experiment(self): 170 | "assign primary unit to an experiment" 171 | segment = self.get_segment() 172 | # is the unit allocated to an experiment? 173 | if segment in self.segment_allocations: 174 | experiment_name = self.segment_allocations[segment] 175 | experiment = self.current_experiments[ 176 | experiment_name](**self.inputs) 177 | experiment.name = '%s-%s' % (self.name, experiment_name) 178 | experiment.salt = '%s.%s' % (self.name, experiment_name) 179 | self._experiment = experiment 180 | self._in_experiment = experiment.in_experiment 181 | # if the unit does not belong to an experiment, or the unit has been 182 | # disqualified from being in the assigned experiment, fall back on the 183 | # default experiment 184 | if not self._in_experiment: 185 | self._assign_default_experiment() 186 | 187 | def _assign_default_experiment(self): 188 | self._default_experiment = self.default_experiment_class(**self.inputs) 189 | 190 | @requires_default_experiment 191 | def default_get(self, name, default=None): 192 | return self._default_experiment.get(name, default) 193 | 194 | @property 195 | @requires_experiment 196 | def in_experiment(self): 197 | return self._in_experiment 198 | 199 | @in_experiment.setter 200 | def in_experiment(self, value): 201 | # in_experiment cannot be externally modified 202 | pass 203 | 204 | @requires_experiment 205 | def set_auto_exposure_logging(self, value): 206 | self._experiment.set_auto_exposure_logging(value) 207 | 208 | @requires_experiment 209 | def get(self, name, default=None): 210 | if self._experiment is None: 211 | return self.default_get(name, default) 212 | else: 213 | return self._experiment.get(name, self.default_get(name, default)) 214 | 215 | @requires_experiment 216 | def log_exposure(self, extras=None): 217 | """Logs exposure to treatment""" 218 | if self._experiment is None: 219 | pass 220 | self._experiment.log_exposure(extras) 221 | 222 | @requires_experiment 223 | def log_event(self, event_type, extras=None): 224 | """Log an arbitrary event""" 225 | if self._experiment is None: 226 | pass 227 | self._experiment.log_event(event_type, extras) 228 | --------------------------------------------------------------------------------