├── .gitignore ├── .travis.yml ├── tests ├── TestLogger.php ├── ExperimentTest.php ├── NamespaceTest.php ├── AssignmentTest.php └── RandomOperatorTest.php ├── src └── Vimeo │ └── ABLincoln │ ├── Experiments │ ├── SimpleExperiment.php │ ├── DefaultExperiment.php │ ├── Logging │ │ ├── PSRLoggerTrait.php │ │ └── FileLoggerTrait.php │ └── AbstractExperiment.php │ ├── Operators │ ├── Random │ │ ├── BernoulliTrial.php │ │ ├── UniformChoice.php │ │ ├── RandomInteger.php │ │ ├── RandomFloat.php │ │ ├── BernoulliFilter.php │ │ ├── Sample.php │ │ └── WeightedChoice.php │ ├── AbstractSimpleOperator.php │ └── RandomOperator.php │ ├── Namespaces │ ├── AbstractNamespace.php │ └── SimpleNamespace.php │ └── Assignment.php ├── phpunit.xml ├── composer.json ├── LICENSE ├── README.md └── composer.lock /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 5.6 5 | - 5.5 6 | - 5.4 7 | - hhvm 8 | 9 | before_script: composer install 10 | -------------------------------------------------------------------------------- /tests/TestLogger.php: -------------------------------------------------------------------------------- 1 | log[] = $message; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Vimeo/ABLincoln/Experiments/SimpleExperiment.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | ./tests/ 9 | 10 | 11 | 12 | 13 | 14 | ./ 15 | 16 | ./tests 17 | ./vendor 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vimeo/ablincoln", 3 | "description": "PHP library for designing online experiments", 4 | "license": "BSD", 5 | "authors": [ 6 | { 7 | "name": "Alex Kalicki", 8 | "email": "alexander.kalicki@gmail.com" 9 | }, 10 | { 11 | "name": "Jacob Oliver", 12 | "email": "jake@vimeo.com" 13 | } 14 | ], 15 | "require": { 16 | "php": ">=5.4.0", 17 | "psr/log": "~1.0", 18 | "monolog/monolog": "~1.0" 19 | }, 20 | "require-dev": { 21 | "phpunit/phpunit": "4.1.*" 22 | }, 23 | "autoload": { 24 | "psr-0": { 25 | "Vimeo\\ABLincoln\\": "src/" 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /src/Vimeo/ABLincoln/Operators/Random/BernoulliTrial.php: -------------------------------------------------------------------------------- 1 | parameters)) { 24 | throw new \Exception(get_class($this) . ": input 'p' required."); 25 | } 26 | 27 | $p = $this->parameters['p']; 28 | $rand_val = $this->_getUniform(0.0, 1.0); 29 | 30 | if (!is_numeric($p) || $p < 0.0 || $p > 1.0) { 31 | throw new \Exception(get_class($this) . ": 'p' must be a number between 0.0 and 1.0, not $p."); 32 | } 33 | 34 | return ($rand_val <= $p) ? 1 : 0; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Vimeo/ABLincoln/Operators/Random/UniformChoice.php: -------------------------------------------------------------------------------- 1 | parameters)) { 24 | throw new \Exception(get_class($this) . ": input 'choices' required."); 25 | } 26 | if (!is_array($this->parameters['choices'])) { 27 | throw new \Exception(get_class($this) . ": 'choices' must be an array."); 28 | } 29 | 30 | $choices = array_values($this->parameters['choices']); 31 | $num_choices = count($choices); 32 | if (!$num_choices) { 33 | return []; 34 | } 35 | return $choices[$this->_getHash() % $num_choices]; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Vimeo/ABLincoln/Operators/Random/RandomInteger.php: -------------------------------------------------------------------------------- 1 | parameters) || !array_key_exists('max', $this->parameters)) { 25 | throw new \Exception(get_class($this) . ": inputs 'min' and 'max' required."); 26 | } 27 | 28 | $min_val = $this->parameters['min']; 29 | $max_val = $this->parameters['max']; 30 | 31 | if (!is_numeric($min_val) || !is_numeric($max_val)) { 32 | throw new \Exception(get_class($this) . ": 'min' and 'max' must both be numeric integer values."); 33 | } 34 | 35 | return $min_val + $this->_getHash() % ($max_val - $min_val + 1); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Vimeo/ABLincoln/Operators/Random/RandomFloat.php: -------------------------------------------------------------------------------- 1 | parameters) || !array_key_exists('max', $this->parameters)) { 25 | throw new \Exception(get_class($this) . ": inputs 'min' and 'max' required."); 26 | } 27 | 28 | $min_val = $this->parameters['min']; 29 | $max_val = $this->parameters['max']; 30 | 31 | if (!is_numeric($min_val) || !is_numeric($max_val)) { 32 | throw new \Exception(get_class($this) . ": 'min' and 'max' must both be numeric values."); 33 | } 34 | 35 | return $this->_getUniform($min_val, $max_val); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD License 2 | 3 | Copyright (c) 2014, Vimeo, LLC. All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, 6 | are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | * Neither the name Vimeo nor the names of its contributors may be used to 16 | endorse or promote products derived from this software without specific 17 | prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 20 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 21 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 23 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 24 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 26 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 28 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /src/Vimeo/ABLincoln/Operators/Random/BernoulliFilter.php: -------------------------------------------------------------------------------- 1 | parameters) || !array_key_exists('choices', $this->parameters)) { 25 | throw new \Exception(get_class($this) . ": inputs 'p' and 'choices' required."); 26 | } 27 | 28 | $p = $this->parameters['p']; 29 | $choices = $this->parameters['choices']; 30 | if (!is_numeric($p) || $p < 0.0 || $p > 1.0) { 31 | throw new \Exception(get_class($this) . ": 'p' must be a number between 0.0 and 1.0, not $p."); 32 | } 33 | if (!is_array($choices)) { 34 | throw new \Exception(get_class($this) . ": 'choices' must be an array."); 35 | } 36 | 37 | if (empty($choices)) { 38 | return []; 39 | } 40 | return array_filter($choices, function($item) use ($p) { 41 | return $this->_getUniform(0.0, 1.0, $item) <= $p; 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Vimeo/ABLincoln/Experiments/DefaultExperiment.php: -------------------------------------------------------------------------------- 1 | getDefaultParams() as $key => $val) { 42 | $params->$key = $val; 43 | } 44 | } 45 | 46 | /** 47 | * Default experiments that are just key-value stores should override 48 | * this method 49 | * 50 | * @return array array of default parameters 51 | */ 52 | public function getDefaultParams() 53 | { 54 | return []; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tests/ExperimentTest.php: -------------------------------------------------------------------------------- 1 | $userid] 22 | ); 23 | $experiment->setOverrides(['bar' => 42]); 24 | $experiment->setLogger($logger); 25 | $params = $experiment->getParams(); 26 | 27 | $this->assertTrue(array_key_exists('foo', $params)); 28 | $this->assertEquals($params['foo'], 'b'); 29 | $this->assertEquals($params['bar'], 42); 30 | $this->assertEquals(count($logger->log), 1); 31 | 32 | $experiment = new TestVanillaExperiment( 33 | ['userid' => $userid, 'username' => $username] 34 | ); 35 | $experiment->setLogger($logger); 36 | $params = $experiment->getParams(); 37 | $this->assertTrue(array_key_exists('foo', $params)); 38 | $this->assertEquals($params['foo'], 'a'); 39 | $this->assertEquals(count($logger->log), 2); 40 | } 41 | } 42 | 43 | class TestVanillaExperiment extends AbstractExperiment 44 | { 45 | use Logging\PSRLoggerTrait; 46 | 47 | public function setup() 48 | { 49 | $this->name = 'test_name'; 50 | $this->setLogLevel('debug'); 51 | } 52 | 53 | public function assign($params, $inputs) 54 | { 55 | $params->foo = new Random\UniformChoice( 56 | ['choices' => ['a', 'b']], 57 | $inputs 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Vimeo/ABLincoln/Operators/Random/Sample.php: -------------------------------------------------------------------------------- 1 | parameters)) { 25 | throw new \Exception(get_class($this) . ": input 'choices' required."); 26 | } 27 | if (!is_array($this->parameters['choices'])) { 28 | throw new \Exception(get_class($this) . ": 'choices' must be an array."); 29 | } 30 | 31 | $choices = array_values($this->parameters['choices']); 32 | $num_choices = count($choices); 33 | 34 | if (array_key_exists('draws', $this->parameters)) { 35 | if (!is_numeric($this->parameters['draws'])) { 36 | throw new \Exception(get_class($this) . ": if given, 'draws' must be a numeric integer value."); 37 | } 38 | if ($this->parameters['draws'] > $num_choices) { 39 | throw new \Exception(get_class($this) . ": cannot make more draws than there are choices available."); 40 | } 41 | $num_draws = $this->parameters['draws']; 42 | } 43 | else { 44 | $num_draws = $num_choices; 45 | } 46 | 47 | for ($i = $num_choices - 1; $i > 0; $i--) { 48 | $j = $this->_getHash($i) % ($i + 1); 49 | $temp = $choices[$i]; 50 | $choices[$i] = $choices[$j]; 51 | $choices[$j] = $temp; 52 | } 53 | return array_slice($choices, 0, $num_draws); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/NamespaceTest.php: -------------------------------------------------------------------------------- 1 | $userid1, 'username' => $username1] 22 | ); 23 | $foo = $namespace->get('foo'); 24 | $this->assertEquals($foo, 2); 25 | 26 | $namespace = new TestVanillaNamespace( 27 | ['userid' => $userid2, 'username' => $username2] 28 | ); 29 | $foo = $namespace->get('foo'); 30 | $this->assertEquals($foo, 'a'); 31 | } 32 | } 33 | 34 | class TestVanillaNamespace extends SimpleNamespace 35 | { 36 | public function setup() 37 | { 38 | $this->name = 'namespace_demo'; 39 | $this->primary_unit = 'userid'; 40 | $this->num_segments = 1000; 41 | } 42 | 43 | public function setupExperiments() 44 | { 45 | $this->addExperiment('first', 'TestExperiment', 300); 46 | $this->addExperiment('second', 'TestExperiment2', 700); 47 | } 48 | } 49 | 50 | class TestExperiment extends AbstractExperiment 51 | { 52 | use Logging\PSRLoggerTrait; 53 | 54 | public function setup() 55 | { 56 | $this->name = 'test_name'; 57 | } 58 | 59 | public function assign($params, $inputs) 60 | { 61 | $params->foo = new Random\UniformChoice( 62 | ['choices' => ['a', 'b']], 63 | $inputs 64 | ); 65 | } 66 | } 67 | 68 | class TestExperiment2 extends AbstractExperiment 69 | { 70 | use Logging\PSRLoggerTrait; 71 | 72 | public function setup() 73 | { 74 | $this->name = 'test2_name'; 75 | } 76 | 77 | public function assign($params, $inputs) 78 | { 79 | $params->foo = new Random\UniformChoice( 80 | ['choices' => [1, 2, 3]], 81 | $inputs 82 | ); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Vimeo/ABLincoln/Operators/AbstractSimpleOperator.php: -------------------------------------------------------------------------------- 1 | args = $options; 27 | $this->args['unit'] = $inputs; 28 | } 29 | 30 | /** 31 | * Evaluate all parameters and store as instance variables, then execute 32 | * the operator as defined in simpleExecute() 33 | * 34 | * @param Assignment $assignment object used to evaluate parameters 35 | * @return mixed the evaluated expression 36 | */ 37 | public function execute($assignment) 38 | { 39 | $this->assignment = $assignment; 40 | $this->parameters = []; // evaluated parameters 41 | foreach ($this->args as $key => $val) { 42 | $this->parameters[$key] = $assignment->evaluate($val); 43 | } 44 | return $this->_simpleExecute(); 45 | } 46 | 47 | /** 48 | * Implement with operator functionality 49 | * 50 | * @return mixed the evaluated expression 51 | */ 52 | abstract protected function _simpleExecute(); 53 | 54 | /** 55 | * Argument accessor 56 | * 57 | * @return array operator arguments 58 | */ 59 | public function args() 60 | { 61 | return $this->args; 62 | } 63 | 64 | /** 65 | * Argument setter 66 | * 67 | * @param mixed $key name of argument to set 68 | * @param mixed $value value to set argument 69 | */ 70 | public function setArg($key, $value) 71 | { 72 | $this->args[$key] = $value; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Vimeo/ABLincoln/Experiments/Logging/PSRLoggerTrait.php: -------------------------------------------------------------------------------- 1 | ALLOWED_LOG_LEVELS)) { 31 | throw new \Exception(get_class($this) . ": 'level' must be one of the constants defined in PSR\Log\LogLevel, not $level."); 32 | } 33 | 34 | $this->log_level = $level; 35 | } 36 | 37 | /** 38 | * PSR logger configuration can either be done outside of the experiment 39 | * class or within this method if overriden. Either way, the initialized 40 | * logger should be passed to the experiment via the LoggerAwareTrait's 41 | * setLogger() method. 42 | */ 43 | protected function _configureLogger() {} 44 | 45 | /** 46 | * Use the logging instance to log an optional message as well as exposure 47 | * data. The log level may be set by the included setLogLevel() method. 48 | * 49 | * @param array $data exposure log data to record 50 | */ 51 | protected function _log($data) 52 | { 53 | if (isset($this->logger)) { 54 | $this->logger->log($this->log_level, json_encode($data)); 55 | } 56 | } 57 | 58 | /** 59 | * Assume data has never been logged before and this is the first time we 60 | * are seeing the inputs/outputs given (can be overriden if needed). 61 | */ 62 | protected function _previouslyLogged() 63 | { 64 | return false; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Vimeo/ABLincoln/Namespaces/AbstractNamespace.php: -------------------------------------------------------------------------------- 1 | tester_salt); 20 | $this->assertFalse(isset($assignment->foo)); 21 | $assignment->foo = 5; 22 | $this->assertTrue(isset($assignment->foo)); 23 | unset($assignment->foo); 24 | $this->assertFalse(isset($assignment->foo)); 25 | } 26 | 27 | /** 28 | * Test Assignment indexing at constant values 29 | */ 30 | public function testSetGetConstants() 31 | { 32 | $assignment = new Assignment($this->tester_salt); 33 | $assignment->foo = 12; 34 | $assignment->bar = 'baz'; 35 | $this->assertEquals($assignment->foo, 12); 36 | $this->assertEquals($assignment->bar, 'baz'); 37 | } 38 | 39 | /** 40 | * Test Assignment RandomOperator setting using UniformChoice 41 | */ 42 | public function testSetGetUniform() 43 | { 44 | $assignment = new Assignment($this->tester_salt); 45 | $assignment->foo = new Random\UniformChoice( 46 | ['choices' => ['a', 'b']], 47 | ['unit' => $this->tester_unit] 48 | ); 49 | $assignment->bar = new Random\UniformChoice( 50 | ['choices' => ['a', 'b']], 51 | ['unit' => $this->tester_unit] 52 | ); 53 | $assignment->baz = new Random\UniformChoice( 54 | ['choices' => ['a', 'b']], 55 | ['unit' => $this->tester_unit] 56 | ); 57 | 58 | $this->assertEquals($assignment->foo, 'b'); 59 | $this->assertEquals($assignment->bar, 'a'); 60 | $this->assertEquals($assignment->baz, 'a'); 61 | } 62 | 63 | /** 64 | * Test Assignment override functionality 65 | */ 66 | public function testOverrides() 67 | { 68 | $assignment = new Assignment($this->tester_salt); 69 | $assignment->setOverrides(['x' => 42, 'y' => 43]); 70 | $assignment->x = 5; 71 | $assignment->y = 6; 72 | $this->assertEquals($assignment->x, 42); 73 | $this->assertEquals($assignment->y, 43); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Vimeo/ABLincoln/Experiments/Logging/FileLoggerTrait.php: -------------------------------------------------------------------------------- 1 | name, self::$loggers)) { 32 | $this->setLogger(self::$loggers[$this->name]); 33 | return; 34 | } 35 | 36 | // if file path not set for experiment default to 'experiment_name.log' 37 | if (!array_key_exists($this->name, self::$file_paths)) { 38 | self::$file_paths[$this->name] = $this->name . '.log'; 39 | } 40 | 41 | // create new logger with channel=experiment_name and given level/path 42 | $logger = new Logger($this->name); 43 | $handler = new StreamHandler(self::$file_paths[$this->name], $this->log_level); 44 | 45 | // format to ignore empty context + extra arrays 46 | $handler->setFormatter(new LineFormatter(null, null, false, true)); 47 | $logger->pushHandler($handler); 48 | 49 | $this->setLogger($logger); 50 | self::$loggers[$this->name] = $logger; 51 | } 52 | 53 | /** 54 | * Set the file path to log to - if not given, file name defaults to 55 | * 'experiment_name.log'. This function should be called in the experiment 56 | * setup() method so that the file path is set before the logger gets 57 | * instantiated. 58 | * 59 | * @param string $path file path to log to 60 | */ 61 | public function setLogFile($path) 62 | { 63 | self::$file_paths[$this->name] = $path; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Vimeo/ABLincoln/Operators/RandomOperator.php: -------------------------------------------------------------------------------- 1 | long_scale = floatval(0xFFFFFFFFFFFFFFF); 28 | } 29 | 30 | /** 31 | * Format all units into an array before hashing 32 | * 33 | * @param mixed $appended_unit optional extra unit used for hashing 34 | * @return array array of units used for hashing 35 | */ 36 | private function _getUnit($appended_unit = null) 37 | { 38 | $unit = $this->parameters['unit']; 39 | if (!is_array($unit)) { 40 | $unit = [$unit]; 41 | } 42 | if (!is_null($appended_unit)) { 43 | $unit[] = $appended_unit; 44 | } 45 | return $unit; 46 | } 47 | 48 | /** 49 | * Form a complete salt string and hash it to a number 50 | * 51 | * @param mixed $appended_unit optional extra unit used for hashing 52 | * @return int decimal representation of computed SHA1 hash 53 | */ 54 | protected function _getHash($appended_unit = null) 55 | { 56 | $salt = $this->parameters['salt']; 57 | $salty = $this->assignment->experiment_salt . '.' . $salt; 58 | $unit_str_arr = array_map('strval', $this->_getUnit($appended_unit)); 59 | $unit_str = implode('.', $unit_str_arr); 60 | return hexdec(substr(sha1($salty . '.' . $unit_str), 0, 15)); 61 | } 62 | 63 | /** 64 | * Get a random decimal between two provided values 65 | * 66 | * @param float $min_value start value for random number range 67 | * @param float $max_value end value for random number range 68 | * @return float random number between the two provided values 69 | */ 70 | protected function _getUniform($min_val = 0.0, $max_val = 1.0, 71 | $appended_unit = null) 72 | { 73 | $zero_to_one = $this->_getHash($appended_unit) / $this->long_scale; 74 | return $min_val + $zero_to_one * ($max_val - $min_val); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Vimeo/ABLincoln/Operators/Random/WeightedChoice.php: -------------------------------------------------------------------------------- 1 | parameters) || !array_key_exists('weights', $this->parameters)) { 25 | throw new \Exception(get_class($this) . ": inputs 'choices' and 'weights' required."); 26 | } 27 | if (!is_array($this->parameters['choices']) || !is_array($this->parameters['weights'])) { 28 | throw new \Exception(get_class($this) . ": 'choices' and 'weights' must be arrays."); 29 | } 30 | 31 | $choices = array_values($this->parameters['choices']); 32 | $weights = array_values($this->parameters['weights']); 33 | 34 | if (count($choices) !== count($weights)) { 35 | throw new \Exception(get_class($this) . ": 'choices' and 'weights' must have the same length."); 36 | } 37 | if (count($choices) == 0 || count($weights) == 0) { 38 | throw new \Exception(get_class($this) . ": 'choices' and 'weights' must have at least one element."); 39 | } 40 | 41 | $non_numeric_weights = array_filter($weights, function($item) { 42 | return !is_numeric($item) || $item < 0.0; 43 | }); 44 | if (count($non_numeric_weights) > 0) { 45 | throw new \Exception(get_class($this) . ": 'weights' must contain only non-negative numbers."); 46 | } 47 | 48 | // initialize array for making weighted draw 49 | $cum_sum = 0.0; 50 | $cum_weights = []; 51 | for ($i = 0; $i < count($choices); $i++) { 52 | $cum_sum += $weights[$i]; 53 | $cum_weights[$i] = $cum_sum; 54 | } 55 | if ($cum_sum == 0) { 56 | throw new \Exception(get_class($this) . ": the sum of values in 'weights' must be positive."); 57 | } 58 | 59 | // find first choice where cumulative weight is > stopping value 60 | $stop_value = $this->_getUniform(0.0, $cum_sum); 61 | for ($i = 0; $i < count($choices); $i++) { 62 | if ($stop_value <= $cum_weights[$i] && $cum_weights[$i] > 0) { 63 | return $choices[$i]; 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Vimeo/ABLincoln/Assignment.php: -------------------------------------------------------------------------------- 1 | experiment_salt = $experiment_salt; 25 | $this->overrides = $overrides; 26 | $this->data = $overrides; 27 | } 28 | 29 | /** 30 | * Evaluate a given parameter and return its value. Currently just directly 31 | * returns the given argument but can be modified with more complex behavior 32 | * 33 | * @param mixed $value the parameter to evaluate 34 | */ 35 | public function evaluate($value) 36 | { 37 | return $value; 38 | } 39 | 40 | /** 41 | * Get the array representation of this Assignment 42 | * 43 | * @return array the Assignment's array representation 44 | */ 45 | public function asArray() 46 | { 47 | return $this->data; 48 | } 49 | 50 | /** 51 | * Get an array of all Assignment parameter overrides 52 | * 53 | * @return array the override array 54 | */ 55 | public function getOverrides() 56 | { 57 | return $this->overrides; 58 | } 59 | 60 | /** 61 | * Set overrides for the Assignment parameters 62 | * 63 | * @param array $overrides parameter name/value pairs to use as overrides 64 | */ 65 | public function setOverrides($overrides) 66 | { 67 | $this->overrides = $overrides; 68 | $this->data = array_replace($this->data, $overrides); 69 | } 70 | 71 | /** 72 | * Check if a given key is set in the Assignment object 73 | * 74 | * @param string $name key to check for in the object 75 | * @return boolean true if key set, false otherwise 76 | */ 77 | public function __isset($name) 78 | { 79 | if ($name === 'experiment_salt') { 80 | return isset($this->experiment_salt); 81 | } 82 | 83 | return isset($this->data[$name]); 84 | } 85 | 86 | /** 87 | * Get the value of a key in the Assignment object if it exists 88 | * 89 | * @param string $name key to obtain the value of 90 | * @return mixed value of given key if it exists, null otherwise 91 | */ 92 | public function __get($name) 93 | { 94 | if ($name === 'experiment_salt') { 95 | return $this->experiment_salt; 96 | } 97 | 98 | return array_key_exists($name, $this->data) ? $this->data[$name] : null; 99 | } 100 | 101 | /** 102 | * Set the value of a key in the object using the parameter name as salt 103 | * 104 | * @param string $name key to set the value of 105 | * @param mixed $value value to set at the given index 106 | */ 107 | public function __set($name, $value) 108 | { 109 | if ($name === 'experiment_salt') { 110 | $this->experiment_salt = $value; 111 | return; 112 | } 113 | 114 | if (array_key_exists($name, $this->overrides)) { 115 | return; 116 | } 117 | 118 | if ($value instanceof RandomOperator) { 119 | if (!array_key_exists('salt', $value->args())) { 120 | $value->setArg('salt', $name); 121 | } 122 | $this->data[$name] = $value->execute($this); 123 | } 124 | else { 125 | $this->data[$name] = $value; 126 | } 127 | } 128 | 129 | /** 130 | * Unset the value at a given key 131 | * 132 | * @param string $name key to unset the value of 133 | */ 134 | public function __unset($name) 135 | { 136 | if ($name === 'experiment_salt') { 137 | unset($this->experiment_salt); 138 | return; 139 | } 140 | 141 | unset($this->data[$name]); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ABLincoln [![Latest Stable Version][version image]][packagist link] [![Build Status][build image]][build link] 2 | 3 | [version image]: https://poser.pugx.org/vimeo/ablincoln/v/stable.svg 4 | [packagist link]: https://packagist.org/packages/vimeo/ablincoln 5 | [build image]: https://travis-ci.org/vimeo/ABLincoln.svg?branch=master 6 | [build link]: https://travis-ci.org/vimeo/ABLincoln 7 | 8 | ABLincoln is a PHP-based implementation of Facebook's [PlanOut], a framework 9 | for online field experimentation. ABLincoln makes it easy to deploy and 10 | maintain sophisticated randomized experiments and to quickly iterate on these 11 | experiments, while satisfying the constraints of large-scale Internet services 12 | with many users. 13 | 14 | [PlanOut]: http://facebook.github.io/planout/ 15 | 16 | Developers integrate ABLincoln by defining experiments that detail how _inputs_ 17 | (e.g. users, cookie IDs) should get mapped onto conditions. To set up an 18 | experiment randomizing both the text and color of a button, you would create a 19 | class like this: 20 | 21 | ```php 22 | use \Vimeo\ABLincoln\Experiments\SimpleExperiment; 23 | use \Vimeo\ABLincoln\Operators\Random as Random; 24 | 25 | class MyExperiment extends SimpleExperiment 26 | { 27 | public function assign($params, $inputs) 28 | { 29 | $params->button_color = new Random\UniformChoice( 30 | ['choices' => ['#ff0000', '#00ff00']], 31 | $inputs 32 | ); 33 | $params->button_text = new Random\WeightedChoice( 34 | [ 35 | 'choices' => ['Join now!', 'Sign up.'], 36 | 'weights' => [0.3, 0.7] 37 | ], 38 | $inputs 39 | ); 40 | } 41 | } 42 | ``` 43 | 44 | Then, in the application code, you query the Experiment object to find out what 45 | values the current user should be mapped onto: 46 | 47 | ```php 48 | $my_exp = new MyExperiment(['userid' => 42]); 49 | $my_exp->get('button_color'); 50 | $my_exp->get('button_text'); 51 | ``` 52 | 53 | Querying the experiment parameters automatically generates an exposure log that 54 | we can direct to a location of our choice: 55 | 56 | ``` 57 | {"name": "MyExperiment", "time": 1421622363, "salt": "MyExperiment", "inputs": {"userid": 42}, "params": {"button_color": "#ff0000", "button_text": "Join now!"}, "event": "exposure"} 58 | ``` 59 | 60 | The basic `SimpleExperiment` class logs to a local file by default. More 61 | advanced behavior, such as Vimeo's methodology [described below][logging], can 62 | easily be introduced to better integrate with your existing logging stack. 63 | 64 | [logging]: #application-to-an-existing-logging-stack 65 | 66 | ### Installation 67 | 68 | ABLincoln is maintained as an independent PHP [Composer][] package hosted on 69 | [Packagist][]. Include it in in your `composer.json` file for nice autoloading 70 | and dependency management: 71 | 72 | ``` 73 | { 74 | "require": { 75 | "vimeo/ablincoln": "~1.0" 76 | } 77 | } 78 | ``` 79 | 80 | [Composer]: https://getcomposer.org/ 81 | [Packagist]: https://packagist.org/packages/vimeo/ablincoln 82 | 83 | ### Comparison with the PlanOut Reference Implementation 84 | 85 | ABLincoln and the original Python release of PlanOut are very similar in both 86 | functionality and usage. Both packages implement abstract and concrete versions 87 | of the Experiment and Namespace classes, parameter overrides to facilitate 88 | testing, exposure logging systems, and various random assignment operators 89 | tested and confirmed to produce identical outputs. 90 | 91 | Notable differences between the two releases currently include: 92 | - ABLincoln features native support for logging either to local files or 93 | to any PSR-compliant stack through the use of included PHP logging traits 94 | - ABLincoln does not currently include an interpreter for the 95 | [PlanOut language][]. 96 | 97 | [PlanOut language]: http://facebook.github.io/planout/docs/planout-language.html 98 | 99 | ### Usage in Production Environments 100 | 101 | ABLincoln was ported and designed with scalability in mind. Here are a couple 102 | ways that Vimeo has chosen to extend it to meet the needs of our testing 103 | process: 104 | 105 | ##### Application to an Existing Logging Stack 106 | 107 | The Experiment [logging traits][] provided with this port make it easy to log 108 | exposure data in the most convenient way possible for your existing stack. A 109 | quick implementation of the plug-and-play [FileLoggerTrait][] and`tail -f` of 110 | the log file is all you need to monitor parameter exposures in real-time. 111 | Alternatively, the [PSRLoggerTrait][] allows more customizable integration with 112 | existing PSR-compliant logging code. Here at Vimeo, we use a basic 113 | [Monolog Handler][] to enforce PSR-3 compliance and allow PlanOut to talk 114 | nicely to our existing logging infrastructure. 115 | 116 | [logging traits]: https://github.com/vimeo/ABLincoln/tree/master/src/Vimeo/ABLincoln/Experiments/Logging 117 | [FileLoggerTrait]: src/Vimeo/ABLincoln/Experiments/Logging/FileLoggerTrait.php 118 | [PSRLoggerTrait]: src/Vimeo/ABLincoln/Experiments/Logging/PSRLoggerTrait.php 119 | [Monolog Handler]: https://github.com/Seldaek/monolog 120 | 121 | ##### URL Overrides 122 | 123 | ABLincoln already supports [parameter overrides][] for quickly examining the 124 | effects of difficult-to-test experiments. A simple way to integrate this 125 | behavior with a live site is to pass overrides into your endpoint as a query 126 | parameter: 127 | 128 | ``` 129 | http://my.site/home.php?overrides=button_color:green,button_text:hello 130 | ``` 131 | 132 | Then it's a relatively simple task to parse the overrides from the query and 133 | pass them into the PHP Experiment API override methods after instantiation. 134 | 135 | [parameter overrides]: http://facebook.github.io/planout/docs/testing.html 136 | 137 | ### Thanks 138 | 139 | [PlanOut][], the software from which ABLincoln was ported, was originally 140 | developed by Eytan Bakshy, Dean Eckles, and Michael S. Bernstein, and is 141 | currently maintained by [Eytan Bakshy][] at Facebook. Learn more 142 | about good practice in large-scale testing by reading their paper, 143 | [Designing and Deploying Online Field Experiments][PlanOut Paper]. 144 | 145 | [Eytan Bakshy]: https://github.com/eytan 146 | [PlanOut]: https://github.com/facebook/planout 147 | [PlanOut Paper]: http://www-personal.umich.edu/~ebakshy/planout.pdf 148 | -------------------------------------------------------------------------------- /src/Vimeo/ABLincoln/Experiments/AbstractExperiment.php: -------------------------------------------------------------------------------- 1 | inputs = $inputs; // input data 33 | $this->name = get_class($this); // use class name as default name 34 | $this->setup(); // manually set name, salt, etc. 35 | $this->assignment = new Assignment($this->salt());; 36 | } 37 | 38 | /* 39 | * Optionally set experiment attributes before run, e.g. name and salt 40 | */ 41 | public function setup() {} 42 | 43 | /** 44 | * Checks if an assignment has been made, assigns one if not 45 | */ 46 | private function _requiresAssignment() 47 | { 48 | if (!$this->assigned) { 49 | $this->_assignSetup(); 50 | } 51 | } 52 | 53 | /** 54 | * Assignment and setup that happens when we need to log data 55 | */ 56 | private function _assignSetup() 57 | { 58 | $this->_configureLogger(); 59 | $this->assign($this->assignment, $this->inputs); 60 | $this->in_experiment = array_key_exists('in_experiment', $this->assignment->asArray()) ? $this->assignment['in_experiment'] : $this->in_experiment; 61 | $this->logged = $this->_previouslyLogged(); 62 | $this->assigned = true; 63 | } 64 | 65 | /** 66 | * Add parameters used in experiment to current assignment 67 | * 68 | * @param Assignment $params assignment in which to place new parameters 69 | * @param array $inputs input data to determine parameter assignments 70 | */ 71 | abstract public function assign($params, $inputs); 72 | 73 | /** 74 | * Sets variables that are to remain fixed during execution. Note that 75 | * setting this will overwrite inputs to the experiment 76 | * 77 | * @param array $overrides parameter name/value pairs to use as overrides 78 | */ 79 | public function setOverrides($overrides) 80 | { 81 | $this->assignment->setOverrides($overrides); 82 | 83 | if (is_array($this->inputs)) { 84 | foreach ($overrides as $key => $val) { 85 | if (array_key_exists($key, $this->inputs)) { 86 | $this->inputs[$key] = $val; 87 | } 88 | } 89 | } 90 | } 91 | 92 | /** 93 | * Experiment-level salt accessor 94 | * 95 | * @return string the experiment-level salt 96 | */ 97 | public function salt() 98 | { 99 | return is_null($this->salt) ? $this->name : $this->salt; 100 | } 101 | 102 | /** 103 | * Experiment-level salt setter 104 | * 105 | * @param string $value value to set the experiment-level salt 106 | */ 107 | public function setSalt($value) 108 | { 109 | $this->salt = $value; 110 | $this->assignment->experiment_salt = $value; 111 | } 112 | 113 | /** 114 | * Experiment name accessor 115 | * 116 | * @return string the experiment name 117 | */ 118 | public function name() 119 | { 120 | return $this->name; 121 | } 122 | 123 | /** 124 | * Experiment name setter 125 | * 126 | * @param string $value value to set the experiment name 127 | */ 128 | public function setName($value) 129 | { 130 | $this->name = preg_replace('/\s+/', '-', $value); 131 | $this->assignment->experiment_salt = $this->salt(); 132 | } 133 | 134 | /** 135 | * In-experiment accessor 136 | * 137 | * @return boolean true if currently in experiment, false otherwise 138 | */ 139 | public function inExperiment() 140 | { 141 | return $this->in_experiment; 142 | } 143 | 144 | /** 145 | * In-experiment setter 146 | * 147 | * @param boolean $value true if currently in experiment, false otherwise 148 | */ 149 | public function setInExperiment($value) 150 | { 151 | $this->in_experiment = $value; 152 | } 153 | 154 | /** 155 | * See whether the experiment has already been exposure logged 156 | * 157 | * @return boolean true if exposure logged, false otherwise 158 | */ 159 | public function exposureLogged() 160 | { 161 | return $this->exposure_logged; 162 | } 163 | 164 | /** 165 | * Set whether the experiment has been exposure logged 166 | * 167 | * @param boolean $value true if exposure logged, false otherwise 168 | */ 169 | public function setExposureLogged($value) 170 | { 171 | $this->exposure_logged = value; 172 | } 173 | 174 | /** 175 | * Disables / enables auto exposure logging (enabled by default) 176 | * 177 | * @param boolean $value true to enable, false to disable 178 | */ 179 | public function setAutoExposureLogging($value) 180 | { 181 | $this->auto_exposure_log = $value; 182 | } 183 | 184 | /** 185 | * Get an array representation of the experiment data 186 | * 187 | * @param array $extras extra data to include in array 188 | * @return array experiment data 189 | */ 190 | protected function _asBlob($extras = []) 191 | { 192 | $this->_requiresAssignment(); 193 | $ret = [ 194 | 'name' => $this->name, 195 | 'time' => time(), 196 | 'salt' => $this->salt(), 197 | 'inputs' => $this->inputs, 198 | 'params' => $this->assignment->asArray() 199 | ]; 200 | return array_merge($ret, $extras); 201 | } 202 | 203 | /** 204 | * Get all experiment parameters - triggers exposure log. In general, this 205 | * should only be used by custom loggers 206 | * 207 | * @return array experiment parameters 208 | */ 209 | public function getParams() 210 | { 211 | $this->_requiresAssignment(); 212 | $this->_requiresExposureLogging(); 213 | return $this->assignment->asArray(); 214 | } 215 | 216 | /** 217 | * Get the value of a given experiment parameter - triggers exposure log 218 | * 219 | * @param string $name parameter to get the value of 220 | * @param string $default optional value to return if parameter undefined 221 | * @return the value of the given parameter 222 | */ 223 | public function get($name, $default = null) 224 | { 225 | $this->_requiresAssignment(); 226 | $this->_requiresExposureLogging(); 227 | return array_key_exists($name, $this->assignment->asArray()) ? $this->assignment->$name : $default; 228 | } 229 | 230 | /** 231 | * JSON representation of exposure log data - triggers exposure log 232 | * 233 | * @return string JSON representation of exposure log data 234 | */ 235 | public function __toString() 236 | { 237 | $this->_requiresAssignment(); 238 | $this->_requiresExposureLogging(); 239 | return json_encode($this->_asBlob()); 240 | } 241 | 242 | /** 243 | * Checks if experiment requires exposure logging, and if so exposure logs 244 | */ 245 | protected function _requiresExposureLogging() 246 | { 247 | if ($this->auto_exposure_log && $this->in_experiment && !$this->exposure_logged) { 248 | $this->logExposure(); 249 | } 250 | } 251 | 252 | /** 253 | * Logs exposure to treatment 254 | * 255 | * @param array $extras optional extra data to include in exposure log 256 | */ 257 | public function logExposure($extras = null) 258 | { 259 | $this->logEvent('exposure', $extras); 260 | $this->exposure_logged = true; 261 | } 262 | 263 | /** 264 | * Log an arbitrary event 265 | * 266 | * @param string $eventType name of event to log 267 | * @param array $extras optional extra data to include in log 268 | */ 269 | public function logEvent($eventType, $extras = null) 270 | { 271 | if (!is_null($extras)) { 272 | $extraPayload = ['event' => $eventType, 'extra_data' => $extras]; 273 | } 274 | else { 275 | $extraPayload = ['event' => $eventType]; 276 | } 277 | $this->_log($this->_asBlob($extraPayload)); 278 | } 279 | 280 | /** 281 | * Set up files, database connections, sockets, etc for logging 282 | */ 283 | abstract protected function _configureLogger(); 284 | 285 | /** 286 | * Log experiment data 287 | * 288 | * @param array $data data to log 289 | */ 290 | abstract protected function _log($data); 291 | 292 | /** 293 | * Check if the input has already been logged. Gets called once in the 294 | * constructor 295 | * 296 | * @return boolean true if previously logged, false otherwise 297 | */ 298 | abstract protected function _previouslyLogged(); 299 | } 300 | -------------------------------------------------------------------------------- /src/Vimeo/ABLincoln/Namespaces/SimpleNamespace.php: -------------------------------------------------------------------------------- 1 | inputs = $inputs; // input data 36 | $this->name = get_class($this); // use class name as default name 37 | $this->num_segments = null; // num_segments set in setup() 38 | $this->in_experiment = false; // not in experiment until unit assigned 39 | 40 | // array mapping segments to experiment names 41 | $this->segment_allocations = []; 42 | 43 | // array mapping experiment names to experiment objects 44 | $this->current_experiments = []; 45 | 46 | $this->experiment = null; // memoized experiment object 47 | $this->default_experiment = null; // memoized default experiment object 48 | $this->default_experiment_class = 'Vimeo\ABLincoln\Experiments\DefaultExperiment'; 49 | 50 | // setup name, primary key, number of segments, etc 51 | $this->setup(); 52 | $this->available_segments = range(0, $this->num_segments - 1); 53 | 54 | $this->setupExperiments(); // load namespace with experiments 55 | } 56 | 57 | /** 58 | * Set namespace attributes for run. Developers extending this class should 59 | * set the following variables: 60 | * this->name = 'sample namespace'; 61 | * this->primary_unit = 'userid'; 62 | * this->num_segments = 10000; 63 | */ 64 | abstract public function setup(); 65 | 66 | /** 67 | * Setup experiments segments will be assigned to: 68 | * $this->addExperiment('first experiment', Exp1, 100); 69 | */ 70 | abstract public function setupExperiments(); 71 | 72 | /** 73 | * Get the primary unit that will be mapped to segments 74 | * 75 | * @return array array containing value(s) used for unit assignment 76 | */ 77 | public function primaryUnit() 78 | { 79 | return $this->primary_unit; 80 | } 81 | 82 | /** 83 | * Set the primary unit that will be used to map to segments 84 | * 85 | * @param mixed $value value or array used for unit assignment to segments 86 | */ 87 | public function setPrimaryUnit($value) 88 | { 89 | $this->primary_unit = is_array($value) ? $value : [$value]; 90 | } 91 | 92 | /** 93 | * In-experiment accessor 94 | * 95 | * @return boolean true if primary unit mapped to an experiment, false otherwise 96 | */ 97 | public function inExperiment() 98 | { 99 | $this->_requiresExperiment(); 100 | return $this->in_experiment; 101 | } 102 | 103 | /** 104 | * Map a new experiment to a given number of segments in the namespace 105 | * 106 | * @param string $name name to give the new experiment 107 | * @param string $exp_class string version of experiment class to instantiate 108 | * @param int $num_segments number of segments to allocate to experiment 109 | */ 110 | public function addExperiment($name, $exp_class, $num_segments) 111 | { 112 | $num_available = count($this->available_segments); 113 | if ($num_available < $num_segments) { 114 | throw new \Exception(get_class($this) . ": $num_segments requested, only $num_available available."); 115 | } 116 | if (array_key_exists($name, $this->current_experiments)) { 117 | throw new \Exception(get_class($this) . ": there is already an experiment called $name."); 118 | } 119 | if (!is_null($this->experiment)) { 120 | throw new \Exception(get_class($this) . ': addExperiment() cannot be called after an assignment is made.'); 121 | } 122 | 123 | // randomly select the given numer of segments from all available options 124 | $assignment = new Assignment($this->name); 125 | $assignment->sampled_segments = new Random\Sample( 126 | ['choices' => $this->available_segments, 'draws' => $num_segments], 127 | ['unit' => $name] 128 | ); 129 | 130 | // assign each segment to the experiment name 131 | foreach ($assignment->sampled_segments as $key => $segment) { 132 | $this->segment_allocations[$segment] = $name; 133 | unset($this->available_segments[$segment]); 134 | } 135 | 136 | // associate the experiment name with a class to instantiate 137 | $this->current_experiments[$name] = $exp_class; 138 | } 139 | 140 | /** 141 | * Remove a given experiment from the namespace and free its associated segments 142 | * 143 | * @param string $name previously defined name of experiment to remove 144 | */ 145 | public function removeExperiment($name) 146 | { 147 | if (!array_key_exists($name, $this->current_experiments)) { 148 | throw new \Exception(get_class($this) . ": there is no experiment called $name."); 149 | } 150 | if (!is_null($this->experiment)) { 151 | throw new \Exception(get_class($this) . ': removeExperiment() cannot be called after an assignment is made.'); 152 | } 153 | 154 | // make segments available for allocation again, remove experiment name 155 | foreach ($this->segment_allocations as $segment => $exp_name) { 156 | if ($exp_name === $name) { 157 | unset($this->segment_allocations[$segment]); 158 | $this->available_segments[$segment] = $segment; 159 | } 160 | } 161 | unset($this->current_experiments[$name]); 162 | } 163 | 164 | /** 165 | * Use the primary unit value(s) to obtain a segment and associated experiment 166 | * 167 | * @return int the segment corresponding to the primary unit value(s) 168 | */ 169 | private function _getSegment() 170 | { 171 | $assignment = new Assignment($this->name); 172 | $assignment->segment = new Random\RandomInteger( 173 | ['min' => 0, 'max' => $this->num_segments - 1], 174 | ['unit' => $this->inputs[$this->primary_unit]] 175 | ); 176 | return $assignment->segment; 177 | } 178 | 179 | /** 180 | * Checks if primary unit segment is assigned to an experiment, 181 | * and if not assigns it to one 182 | */ 183 | protected function _requiresExperiment() 184 | { 185 | if (is_null($this->experiment)) { 186 | $this->_assignExperiment(); 187 | } 188 | } 189 | 190 | /** 191 | * Checks if primary unit segment is assigned to a default experiment, 192 | * and if not assigns it to one 193 | */ 194 | protected function _requiresDefaultExperiment() 195 | { 196 | if (is_null($this->default_experiment)) { 197 | $this->_assignDefaultExperiment(); 198 | } 199 | } 200 | 201 | /** 202 | * Assigns the primary unit value(s) and associated segment to a new 203 | * experiment and updates the experiment name/salt accordingly 204 | */ 205 | private function _assignExperiment() 206 | { 207 | $this->in_experiment = false; 208 | $segment = $this->_getSegment(); 209 | 210 | // is the unit allocated to an experiment? 211 | if (array_key_exists($segment, $this->segment_allocations)) { 212 | $exp_name = $this->segment_allocations[$segment]; 213 | $experiment = new $this->current_experiments[$exp_name]($this->inputs); 214 | $experiment->setName($this->name . '-' . $exp_name); 215 | $experiment->setSalt($this->name . '.' . $exp_name); 216 | $this->experiment = $experiment; 217 | $this->in_experiment = $experiment->inExperiment(); 218 | } 219 | 220 | if (!$this->in_experiment) { 221 | $this->_assignDefaultExperiment(); 222 | } 223 | } 224 | 225 | /** 226 | * Assigns the primary unit value(s) and associated segment to a new 227 | * default experiment used if segment not assigned to a real one 228 | */ 229 | private function _assignDefaultExperiment() 230 | { 231 | $this->default_experiment = new $this->default_experiment_class($this->inputs); 232 | } 233 | 234 | /** 235 | * Get the value of a given experiment parameter - triggers exposure log 236 | * 237 | * @param string $name parameter to get the value of 238 | * @param string $default optional value to return if parameter undefined 239 | * @return the value of the given parameter 240 | */ 241 | public function get($name, $default = null) 242 | { 243 | $this->_requiresExperiment(); 244 | if (is_null($this->experiment)) { 245 | return $this->_defaultGet($name, $default); 246 | } 247 | return $this->experiment->get($name, $this->_defaultGet($name, $default)); 248 | } 249 | 250 | /** 251 | * Get the value of a given default experiment parameter. Called on get() 252 | * if primary unit value(s) not mapped to a real experiment 253 | * 254 | * @param string $name parameter to get the value of 255 | * @param string $default optional value to return if parameter undefined 256 | * @return the value of the given parameter 257 | */ 258 | private function _defaultGet($name, $default = null) 259 | { 260 | $this->_requiresDefaultExperiment(); 261 | return $this->default_experiment->get($name, $default); 262 | } 263 | 264 | /** 265 | * Disables / enables auto exposure logging (enabled by default) 266 | * 267 | * @param boolean $value true to enable, false to disable 268 | */ 269 | public function setAutoExposureLogging($value) 270 | { 271 | $this->_requiresExperiment(); 272 | if (!is_null($this->experiment)) { 273 | $this->experiment->setAutoExposureLogging($value); 274 | } 275 | } 276 | 277 | /** 278 | * Logs exposure to treatment 279 | * 280 | * @param array $extras optional extra data to include in exposure log 281 | */ 282 | public function logExposure($extras = null) 283 | { 284 | $this->_requiresExperiment(); 285 | if (!is_null($this->experiment)) { 286 | $this->experiment->logExposure($extras); 287 | } 288 | } 289 | 290 | /** 291 | * Log an arbitrary event 292 | * 293 | * @param string $eventType name of event to kig] 294 | * @param array $extras optional extra data to include in log 295 | */ 296 | public function logEvent($event_type, $extras = null) 297 | { 298 | $this->_requiresExperiment(); 299 | if (!is_null($this->experiment)) { 300 | $this->experiment->logEvent($event_type, $extras); 301 | } 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /tests/RandomOperatorTest.php: -------------------------------------------------------------------------------- 1 | $mass) { 24 | $value_density[$value] = $mass / $mass_sum; 25 | } 26 | return $value_density; 27 | } 28 | 29 | /** 30 | * Make sure an experiment object generates the desired frequencies 31 | * 32 | * @param function $func experiment object helper method 33 | * @param array $value_mass array containing values and their respective frequencies 34 | * @param int $N total number of outcomes 35 | */ 36 | private function _distributionTester($func, $value_mass, $N = 1000) 37 | { 38 | // run and store the results of $N trials of $func() with input $i 39 | $values = []; 40 | for ($i = 0; $i < $N; $i++) { 41 | $values[] = call_user_func($func, $i); 42 | } 43 | $value_density = self::_valueMassToDensity($value_mass); 44 | 45 | // test outcome frequencies against expected density 46 | $this->_assertProbs($values, $value_density, floatval($N)); 47 | } 48 | 49 | /** 50 | * Make sure an experiment object generates the desired frequencies 51 | * 52 | * @param function $func experiment object helper method 53 | * @param array $value_mass array containing values and their respective frequencies 54 | * @param int $N total number of outcomes 55 | */ 56 | private function _listDistributionTester($func, $value_mass, $N = 1000) 57 | { 58 | // run and store the results of $N trials of $func() with input $i 59 | $values = []; 60 | for ($i = 0; $i < $N; $i++) { 61 | $values[] = call_user_func($func, $i); 62 | } 63 | $value_density = self::_valueMassToDensity($value_mass); 64 | 65 | // transpose values array 66 | $rows = $N; 67 | $cols = count($values[0]); 68 | $values_trans = []; 69 | for ($i = 0; $i < $rows; $i++) { 70 | for ($j = 0; $j < $cols; $j++) { 71 | $values_trans[$j][$i] = $values[$i][$j]; 72 | } 73 | } 74 | 75 | // test outcome frequencies against expected density. each $list is a 76 | // row of the transpose of $values, and is expected to have the same 77 | // distribution as $value_density 78 | foreach ($values_trans as $key => $list) { 79 | $this->_assertProbs($list, $value_density, floatval($N)); 80 | } 81 | } 82 | 83 | /** 84 | * Check that a list of values has roughly the expected density 85 | * 86 | * @param array $values array containing all operator values 87 | * @param array $expected_density array mapping values to expected densities 88 | * @param float $N total number of outcomes 89 | */ 90 | private function _assertProbs($values, $expected_density, $N) 91 | { 92 | $hist = array_count_values($values); 93 | foreach ($hist as $value => $value_sum) { 94 | $this->_assertProp($value_sum / $N, $expected_density[$value], $N); 95 | } 96 | } 97 | 98 | /** 99 | * Test of proportions: normal approximation of binomial CI. This should be 100 | * OK for large N and values of p not too close to 0 or 1 101 | * 102 | * @param float $observed_p observed density of value 103 | * @param float $expected_p expected density of value 104 | * @param float $N total number of outcomes 105 | */ 106 | private function _assertProp($observed_p, $expected_p, $N) 107 | { 108 | $se = self::Z * sqrt($expected_p * (1 - $expected_p) / $N); 109 | $this->assertTrue(abs($observed_p - $expected_p) <= $se); 110 | } 111 | 112 | /** 113 | * Test RandomFloat random operator 114 | */ 115 | public function testFloat() 116 | { 117 | $N = 5; 118 | $min = 0; $max = 1; 119 | FloatHelper::setArgs(['min' => $min, 'max' => $max]); 120 | for ($i = 0; $i < $N; $i++) { 121 | $f = FloatHelper::execute($i); 122 | $this->assertTrue($min <= $f && $f <= $max); 123 | } 124 | $min = 5; $max = 7; 125 | FloatHelper::setArgs(['min' => $min, 'max' => $max]); 126 | for ($i = 0; $i < $N; $i++) { 127 | $f = FloatHelper::execute($i); 128 | $this->assertTrue($min <= $f && $f <= $max); 129 | } 130 | $min = 2; $max = 2; 131 | FloatHelper::setArgs(['min' => $min, 'max' => $max]); 132 | for ($i = 0; $i < $N; $i++) { 133 | $f = FloatHelper::execute($i); 134 | $this->assertTrue($min <= $f && $f <= $max); 135 | } 136 | } 137 | 138 | /** 139 | * Test RandomInteger random operator 140 | */ 141 | public function testInteger() 142 | { 143 | IntegerHelper::setArgs(['min' => 0, 'max' => 1]); 144 | $this->_distributionTester('IntegerHelper::execute', [0 => 1, 1 => 1]); 145 | IntegerHelper::setArgs(['min' => 5, 'max' => 7]); 146 | $this->_distributionTester('IntegerHelper::execute', [5 => 1, 6 => 1, 7 => 1]); 147 | IntegerHelper::setArgs(['min' => 2, 'max' => 2]); 148 | $this->_distributionTester('IntegerHelper::execute', [2 => 1]); 149 | } 150 | 151 | /** 152 | * Test BernoulliTrial random operator 153 | */ 154 | public function testBernoulli() 155 | { 156 | BernoulliHelper::setArgs(['p' => 0.0]); 157 | $this->_distributionTester('BernoulliHelper::execute', [0 => 1, 1 => 0]); 158 | BernoulliHelper::setArgs(['p' => 0.1]); 159 | $this->_distributionTester('BernoulliHelper::execute', [0 => 0.9, 1 => 0.1]); 160 | BernoulliHelper::setArgs(['p' => 1.0]); 161 | $this->_distributionTester('BernoulliHelper::execute', [0 => 0, 1 => 1]); 162 | } 163 | 164 | /** 165 | * Test UniformChoice random operator 166 | */ 167 | public function testUniformChoice() 168 | { 169 | UniformHelper::setArgs(['choices' => ['a']]); 170 | $this->_distributionTester('UniformHelper::execute', ['a' => 1]); 171 | UniformHelper::setArgs(['choices' => ['a', 'b']]); 172 | $this->_distributionTester('UniformHelper::execute', ['a' => 1, 'b' => 1]); 173 | UniformHelper::setArgs(['choices' => [1, 2, 3, 4]]); 174 | $this->_distributionTester('UniformHelper::execute', [1 => 1, 2 => 1, 3 => 1, 4 => 1]); 175 | } 176 | 177 | /** 178 | * Test WeightedChoice random operator 179 | */ 180 | public function testWeightedChoice() 181 | { 182 | $weights = ['a' => 1]; 183 | WeightedHelper::setArgs(['choices' => ['a'], 'weights' => $weights]); 184 | $this->_distributionTester('WeightedHelper::execute', $weights); 185 | $weights = ['a' => 1, 'b' => 2]; 186 | WeightedHelper::setArgs(['choices' => ['a', 'b'], 'weights' => $weights]); 187 | $this->_distributionTester('WeightedHelper::execute', $weights); 188 | $weights = ['a' => 0, 'b' => 2, 'c' => 0]; 189 | WeightedHelper::setArgs(['choices' => ['a', 'b', 'c'], 'weights' => $weights]); 190 | $this->_distributionTester('WeightedHelper::execute', $weights); 191 | 192 | // test distribution with repeated choices 193 | WeightedHelper::setArgs(['choices' => ['a', 'b', 'a'], 'weights' => [1, 2, 3]]); 194 | $this->_distributionTester('WeightedHelper::execute', ['a' => 2, 'b' => 1]); 195 | } 196 | 197 | /** 198 | * Test Sample random operator 199 | */ 200 | public function testSample() 201 | { 202 | SampleHelper::setArgs(['choices' => [1, 2, 3], 'draws' => 3]); 203 | $this->_listDistributionTester('SampleHelper::execute', [1 => 1, 2 => 1, 3 => 1]); 204 | SampleHelper::setArgs(['choices' => [1, 2, 3], 'draws' => 2]); 205 | $this->_listDistributionTester('SampleHelper::execute', [1 => 1, 2 => 1, 3 => 1]); 206 | SampleHelper::setArgs(['choices' => ['a', 'a', 'b'], 'draws' => 3]); 207 | $this->_listDistributionTester('SampleHelper::execute', ['a' => 2, 'b' => 1]); 208 | } 209 | } 210 | 211 | abstract class TestHelper 212 | { 213 | protected static $args; 214 | 215 | public static function setArgs($args) 216 | { 217 | self::$args = $args; 218 | } 219 | 220 | public static function execute($i) {} 221 | } 222 | 223 | class FloatHelper extends TestHelper 224 | { 225 | public static function execute($i) 226 | { 227 | $exp_salt = sprintf('%s,%s', strval(self::$args['min']), strval(self::$args['max'])); 228 | $assignment = new Assignment($exp_salt); 229 | $assignment->x = new Random\RandomFloat( 230 | ['min' => self::$args['min'], 'max' => self::$args['max']], 231 | ['unit' => $i] 232 | ); 233 | return $assignment->x; 234 | } 235 | } 236 | 237 | class IntegerHelper extends TestHelper 238 | { 239 | public static function execute($i) 240 | { 241 | $exp_salt = sprintf('%s,%s', strval(self::$args['min']), strval(self::$args['max'])); 242 | $assignment = new Assignment($exp_salt); 243 | $assignment->x = new Random\RandomInteger( 244 | ['min' => self::$args['min'], 'max' => self::$args['max']], 245 | ['unit' => $i] 246 | ); 247 | return $assignment->x; 248 | } 249 | } 250 | 251 | class BernoulliHelper extends TestHelper 252 | { 253 | public static function execute($i) 254 | { 255 | $assignment = new Assignment(self::$args['p']); 256 | $assignment->x = new Random\BernoulliTrial( 257 | ['p' => self::$args['p']], 258 | ['unit' => $i] 259 | ); 260 | return $assignment->x; 261 | } 262 | } 263 | 264 | class UniformHelper extends TestHelper 265 | { 266 | public static function execute($i) 267 | { 268 | $assignment = new Assignment(implode(',', array_map('strval', self::$args['choices']))); 269 | $assignment->x = new Random\UniformChoice( 270 | ['choices' => self::$args['choices']], 271 | ['unit' => $i] 272 | ); 273 | return $assignment->x; 274 | } 275 | } 276 | 277 | class WeightedHelper extends TestHelper 278 | { 279 | public static function execute($i) 280 | { 281 | $assignment = new Assignment(implode(',', array_map('strval', self::$args['choices']))); 282 | $assignment->x = new Random\WeightedChoice( 283 | ['choices' => self::$args['choices'], 'weights' => self::$args['weights']], 284 | ['unit' => $i] 285 | ); 286 | return $assignment->x; 287 | } 288 | } 289 | 290 | class SampleHelper extends TestHelper 291 | { 292 | public static function execute($i) 293 | { 294 | $assignment = new Assignment(implode(',', array_map('strval', self::$args['choices']))); 295 | $assignment->x = new Random\Sample( 296 | ['choices' => self::$args['choices'], 'draws' => self::$args['draws']], 297 | ['unit' => $i] 298 | ); 299 | return $assignment->x; 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_readme": [ 3 | "This file locks the dependencies of your project to a known state", 4 | "Read more about it at http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", 5 | "This file is @generated automatically" 6 | ], 7 | "hash": "447622a05448d3866dd0aad086a43dfd", 8 | "packages": [ 9 | { 10 | "name": "monolog/monolog", 11 | "version": "1.12.0", 12 | "source": { 13 | "type": "git", 14 | "url": "https://github.com/Seldaek/monolog.git", 15 | "reference": "1fbe8c2641f2b163addf49cc5e18f144bec6b19f" 16 | }, 17 | "dist": { 18 | "type": "zip", 19 | "url": "https://api.github.com/repos/Seldaek/monolog/zipball/1fbe8c2641f2b163addf49cc5e18f144bec6b19f", 20 | "reference": "1fbe8c2641f2b163addf49cc5e18f144bec6b19f", 21 | "shasum": "" 22 | }, 23 | "require": { 24 | "php": ">=5.3.0", 25 | "psr/log": "~1.0" 26 | }, 27 | "provide": { 28 | "psr/log-implementation": "1.0.0" 29 | }, 30 | "require-dev": { 31 | "aws/aws-sdk-php": "~2.4, >2.4.8", 32 | "doctrine/couchdb": "~1.0@dev", 33 | "graylog2/gelf-php": "~1.0", 34 | "phpunit/phpunit": "~4.0", 35 | "raven/raven": "~0.5", 36 | "ruflin/elastica": "0.90.*", 37 | "videlalvaro/php-amqplib": "~2.4" 38 | }, 39 | "suggest": { 40 | "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", 41 | "doctrine/couchdb": "Allow sending log messages to a CouchDB server", 42 | "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", 43 | "ext-mongo": "Allow sending log messages to a MongoDB server", 44 | "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", 45 | "raven/raven": "Allow sending log messages to a Sentry server", 46 | "rollbar/rollbar": "Allow sending log messages to Rollbar", 47 | "ruflin/elastica": "Allow sending log messages to an Elastic Search server", 48 | "videlalvaro/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib" 49 | }, 50 | "type": "library", 51 | "extra": { 52 | "branch-alias": { 53 | "dev-master": "1.12.x-dev" 54 | } 55 | }, 56 | "autoload": { 57 | "psr-4": { 58 | "Monolog\\": "src/Monolog" 59 | } 60 | }, 61 | "notification-url": "https://packagist.org/downloads/", 62 | "license": [ 63 | "MIT" 64 | ], 65 | "authors": [ 66 | { 67 | "name": "Jordi Boggiano", 68 | "email": "j.boggiano@seld.be", 69 | "homepage": "http://seld.be" 70 | } 71 | ], 72 | "description": "Sends your logs to files, sockets, inboxes, databases and various web services", 73 | "homepage": "http://github.com/Seldaek/monolog", 74 | "keywords": [ 75 | "log", 76 | "logging", 77 | "psr-3" 78 | ], 79 | "time": "2014-12-29 21:29:35" 80 | }, 81 | { 82 | "name": "psr/log", 83 | "version": "1.0.0", 84 | "source": { 85 | "type": "git", 86 | "url": "https://github.com/php-fig/log.git", 87 | "reference": "fe0936ee26643249e916849d48e3a51d5f5e278b" 88 | }, 89 | "dist": { 90 | "type": "zip", 91 | "url": "https://api.github.com/repos/php-fig/log/zipball/fe0936ee26643249e916849d48e3a51d5f5e278b", 92 | "reference": "fe0936ee26643249e916849d48e3a51d5f5e278b", 93 | "shasum": "" 94 | }, 95 | "type": "library", 96 | "autoload": { 97 | "psr-0": { 98 | "Psr\\Log\\": "" 99 | } 100 | }, 101 | "notification-url": "https://packagist.org/downloads/", 102 | "license": [ 103 | "MIT" 104 | ], 105 | "authors": [ 106 | { 107 | "name": "PHP-FIG", 108 | "homepage": "http://www.php-fig.org/" 109 | } 110 | ], 111 | "description": "Common interface for logging libraries", 112 | "keywords": [ 113 | "log", 114 | "psr", 115 | "psr-3" 116 | ], 117 | "time": "2012-12-21 11:40:51" 118 | } 119 | ], 120 | "packages-dev": [ 121 | { 122 | "name": "phpunit/php-code-coverage", 123 | "version": "2.0.14", 124 | "source": { 125 | "type": "git", 126 | "url": "https://github.com/sebastianbergmann/php-code-coverage.git", 127 | "reference": "ca158276c1200cc27f5409a5e338486bc0b4fc94" 128 | }, 129 | "dist": { 130 | "type": "zip", 131 | "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/ca158276c1200cc27f5409a5e338486bc0b4fc94", 132 | "reference": "ca158276c1200cc27f5409a5e338486bc0b4fc94", 133 | "shasum": "" 134 | }, 135 | "require": { 136 | "php": ">=5.3.3", 137 | "phpunit/php-file-iterator": "~1.3", 138 | "phpunit/php-text-template": "~1.2", 139 | "phpunit/php-token-stream": "~1.3", 140 | "sebastian/environment": "~1.0", 141 | "sebastian/version": "~1.0" 142 | }, 143 | "require-dev": { 144 | "ext-xdebug": ">=2.1.4", 145 | "phpunit/phpunit": "~4.1" 146 | }, 147 | "suggest": { 148 | "ext-dom": "*", 149 | "ext-xdebug": ">=2.2.1", 150 | "ext-xmlwriter": "*" 151 | }, 152 | "type": "library", 153 | "extra": { 154 | "branch-alias": { 155 | "dev-master": "2.0.x-dev" 156 | } 157 | }, 158 | "autoload": { 159 | "classmap": [ 160 | "src/" 161 | ] 162 | }, 163 | "notification-url": "https://packagist.org/downloads/", 164 | "include-path": [ 165 | "" 166 | ], 167 | "license": [ 168 | "BSD-3-Clause" 169 | ], 170 | "authors": [ 171 | { 172 | "name": "Sebastian Bergmann", 173 | "email": "sb@sebastian-bergmann.de", 174 | "role": "lead" 175 | } 176 | ], 177 | "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", 178 | "homepage": "https://github.com/sebastianbergmann/php-code-coverage", 179 | "keywords": [ 180 | "coverage", 181 | "testing", 182 | "xunit" 183 | ], 184 | "time": "2014-12-26 13:28:33" 185 | }, 186 | { 187 | "name": "phpunit/php-file-iterator", 188 | "version": "1.3.4", 189 | "source": { 190 | "type": "git", 191 | "url": "https://github.com/sebastianbergmann/php-file-iterator.git", 192 | "reference": "acd690379117b042d1c8af1fafd61bde001bf6bb" 193 | }, 194 | "dist": { 195 | "type": "zip", 196 | "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/acd690379117b042d1c8af1fafd61bde001bf6bb", 197 | "reference": "acd690379117b042d1c8af1fafd61bde001bf6bb", 198 | "shasum": "" 199 | }, 200 | "require": { 201 | "php": ">=5.3.3" 202 | }, 203 | "type": "library", 204 | "autoload": { 205 | "classmap": [ 206 | "File/" 207 | ] 208 | }, 209 | "notification-url": "https://packagist.org/downloads/", 210 | "include-path": [ 211 | "" 212 | ], 213 | "license": [ 214 | "BSD-3-Clause" 215 | ], 216 | "authors": [ 217 | { 218 | "name": "Sebastian Bergmann", 219 | "email": "sb@sebastian-bergmann.de", 220 | "role": "lead" 221 | } 222 | ], 223 | "description": "FilterIterator implementation that filters files based on a list of suffixes.", 224 | "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", 225 | "keywords": [ 226 | "filesystem", 227 | "iterator" 228 | ], 229 | "time": "2013-10-10 15:34:57" 230 | }, 231 | { 232 | "name": "phpunit/php-text-template", 233 | "version": "1.2.0", 234 | "source": { 235 | "type": "git", 236 | "url": "https://github.com/sebastianbergmann/php-text-template.git", 237 | "reference": "206dfefc0ffe9cebf65c413e3d0e809c82fbf00a" 238 | }, 239 | "dist": { 240 | "type": "zip", 241 | "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/206dfefc0ffe9cebf65c413e3d0e809c82fbf00a", 242 | "reference": "206dfefc0ffe9cebf65c413e3d0e809c82fbf00a", 243 | "shasum": "" 244 | }, 245 | "require": { 246 | "php": ">=5.3.3" 247 | }, 248 | "type": "library", 249 | "autoload": { 250 | "classmap": [ 251 | "Text/" 252 | ] 253 | }, 254 | "notification-url": "https://packagist.org/downloads/", 255 | "include-path": [ 256 | "" 257 | ], 258 | "license": [ 259 | "BSD-3-Clause" 260 | ], 261 | "authors": [ 262 | { 263 | "name": "Sebastian Bergmann", 264 | "email": "sb@sebastian-bergmann.de", 265 | "role": "lead" 266 | } 267 | ], 268 | "description": "Simple template engine.", 269 | "homepage": "https://github.com/sebastianbergmann/php-text-template/", 270 | "keywords": [ 271 | "template" 272 | ], 273 | "time": "2014-01-30 17:20:04" 274 | }, 275 | { 276 | "name": "phpunit/php-timer", 277 | "version": "1.0.5", 278 | "source": { 279 | "type": "git", 280 | "url": "https://github.com/sebastianbergmann/php-timer.git", 281 | "reference": "19689d4354b295ee3d8c54b4f42c3efb69cbc17c" 282 | }, 283 | "dist": { 284 | "type": "zip", 285 | "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/19689d4354b295ee3d8c54b4f42c3efb69cbc17c", 286 | "reference": "19689d4354b295ee3d8c54b4f42c3efb69cbc17c", 287 | "shasum": "" 288 | }, 289 | "require": { 290 | "php": ">=5.3.3" 291 | }, 292 | "type": "library", 293 | "autoload": { 294 | "classmap": [ 295 | "PHP/" 296 | ] 297 | }, 298 | "notification-url": "https://packagist.org/downloads/", 299 | "include-path": [ 300 | "" 301 | ], 302 | "license": [ 303 | "BSD-3-Clause" 304 | ], 305 | "authors": [ 306 | { 307 | "name": "Sebastian Bergmann", 308 | "email": "sb@sebastian-bergmann.de", 309 | "role": "lead" 310 | } 311 | ], 312 | "description": "Utility class for timing", 313 | "homepage": "https://github.com/sebastianbergmann/php-timer/", 314 | "keywords": [ 315 | "timer" 316 | ], 317 | "time": "2013-08-02 07:42:54" 318 | }, 319 | { 320 | "name": "phpunit/php-token-stream", 321 | "version": "1.3.0", 322 | "source": { 323 | "type": "git", 324 | "url": "https://github.com/sebastianbergmann/php-token-stream.git", 325 | "reference": "f8d5d08c56de5cfd592b3340424a81733259a876" 326 | }, 327 | "dist": { 328 | "type": "zip", 329 | "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/f8d5d08c56de5cfd592b3340424a81733259a876", 330 | "reference": "f8d5d08c56de5cfd592b3340424a81733259a876", 331 | "shasum": "" 332 | }, 333 | "require": { 334 | "ext-tokenizer": "*", 335 | "php": ">=5.3.3" 336 | }, 337 | "require-dev": { 338 | "phpunit/phpunit": "~4.2" 339 | }, 340 | "type": "library", 341 | "extra": { 342 | "branch-alias": { 343 | "dev-master": "1.3-dev" 344 | } 345 | }, 346 | "autoload": { 347 | "classmap": [ 348 | "src/" 349 | ] 350 | }, 351 | "notification-url": "https://packagist.org/downloads/", 352 | "license": [ 353 | "BSD-3-Clause" 354 | ], 355 | "authors": [ 356 | { 357 | "name": "Sebastian Bergmann", 358 | "email": "sebastian@phpunit.de" 359 | } 360 | ], 361 | "description": "Wrapper around PHP's tokenizer extension.", 362 | "homepage": "https://github.com/sebastianbergmann/php-token-stream/", 363 | "keywords": [ 364 | "tokenizer" 365 | ], 366 | "time": "2014-08-31 06:12:13" 367 | }, 368 | { 369 | "name": "phpunit/phpunit", 370 | "version": "4.1.6", 371 | "source": { 372 | "type": "git", 373 | "url": "https://github.com/sebastianbergmann/phpunit.git", 374 | "reference": "241116219bb7e3b8111a36ffd8f37546888738d6" 375 | }, 376 | "dist": { 377 | "type": "zip", 378 | "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/241116219bb7e3b8111a36ffd8f37546888738d6", 379 | "reference": "241116219bb7e3b8111a36ffd8f37546888738d6", 380 | "shasum": "" 381 | }, 382 | "require": { 383 | "ext-dom": "*", 384 | "ext-json": "*", 385 | "ext-pcre": "*", 386 | "ext-reflection": "*", 387 | "ext-spl": "*", 388 | "php": ">=5.3.3", 389 | "phpunit/php-code-coverage": "~2.0", 390 | "phpunit/php-file-iterator": "~1.3.1", 391 | "phpunit/php-text-template": "~1.2", 392 | "phpunit/php-timer": "~1.0.2", 393 | "phpunit/phpunit-mock-objects": "2.1.5", 394 | "sebastian/comparator": "~1.0", 395 | "sebastian/diff": "~1.1", 396 | "sebastian/environment": "~1.0", 397 | "sebastian/exporter": "~1.0", 398 | "sebastian/version": "~1.0", 399 | "symfony/yaml": "~2.0" 400 | }, 401 | "suggest": { 402 | "phpunit/php-invoker": "~1.1" 403 | }, 404 | "bin": [ 405 | "phpunit" 406 | ], 407 | "type": "library", 408 | "extra": { 409 | "branch-alias": { 410 | "dev-master": "4.1.x-dev" 411 | } 412 | }, 413 | "autoload": { 414 | "classmap": [ 415 | "src/" 416 | ] 417 | }, 418 | "notification-url": "https://packagist.org/downloads/", 419 | "include-path": [ 420 | "", 421 | "../../symfony/yaml/" 422 | ], 423 | "license": [ 424 | "BSD-3-Clause" 425 | ], 426 | "authors": [ 427 | { 428 | "name": "Sebastian Bergmann", 429 | "email": "sebastian@phpunit.de", 430 | "role": "lead" 431 | } 432 | ], 433 | "description": "The PHP Unit Testing framework.", 434 | "homepage": "http://www.phpunit.de/", 435 | "keywords": [ 436 | "phpunit", 437 | "testing", 438 | "xunit" 439 | ], 440 | "time": "2014-08-17 08:07:02" 441 | }, 442 | { 443 | "name": "phpunit/phpunit-mock-objects", 444 | "version": "2.1.5", 445 | "source": { 446 | "type": "git", 447 | "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git", 448 | "reference": "7878b9c41edb3afab92b85edf5f0981014a2713a" 449 | }, 450 | "dist": { 451 | "type": "zip", 452 | "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/7878b9c41edb3afab92b85edf5f0981014a2713a", 453 | "reference": "7878b9c41edb3afab92b85edf5f0981014a2713a", 454 | "shasum": "" 455 | }, 456 | "require": { 457 | "php": ">=5.3.3", 458 | "phpunit/php-text-template": "~1.2" 459 | }, 460 | "require-dev": { 461 | "phpunit/phpunit": "~4.1" 462 | }, 463 | "suggest": { 464 | "ext-soap": "*" 465 | }, 466 | "type": "library", 467 | "extra": { 468 | "branch-alias": { 469 | "dev-master": "2.1.x-dev" 470 | } 471 | }, 472 | "autoload": { 473 | "classmap": [ 474 | "src/" 475 | ] 476 | }, 477 | "notification-url": "https://packagist.org/downloads/", 478 | "include-path": [ 479 | "" 480 | ], 481 | "license": [ 482 | "BSD-3-Clause" 483 | ], 484 | "authors": [ 485 | { 486 | "name": "Sebastian Bergmann", 487 | "email": "sb@sebastian-bergmann.de", 488 | "role": "lead" 489 | } 490 | ], 491 | "description": "Mock Object library for PHPUnit", 492 | "homepage": "https://github.com/sebastianbergmann/phpunit-mock-objects/", 493 | "keywords": [ 494 | "mock", 495 | "xunit" 496 | ], 497 | "time": "2014-06-12 07:22:15" 498 | }, 499 | { 500 | "name": "sebastian/comparator", 501 | "version": "1.1.0", 502 | "source": { 503 | "type": "git", 504 | "url": "https://github.com/sebastianbergmann/comparator.git", 505 | "reference": "c484a80f97573ab934e37826dba0135a3301b26a" 506 | }, 507 | "dist": { 508 | "type": "zip", 509 | "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c484a80f97573ab934e37826dba0135a3301b26a", 510 | "reference": "c484a80f97573ab934e37826dba0135a3301b26a", 511 | "shasum": "" 512 | }, 513 | "require": { 514 | "php": ">=5.3.3", 515 | "sebastian/diff": "~1.1", 516 | "sebastian/exporter": "~1.0" 517 | }, 518 | "require-dev": { 519 | "phpunit/phpunit": "~4.1" 520 | }, 521 | "type": "library", 522 | "extra": { 523 | "branch-alias": { 524 | "dev-master": "1.1.x-dev" 525 | } 526 | }, 527 | "autoload": { 528 | "classmap": [ 529 | "src/" 530 | ] 531 | }, 532 | "notification-url": "https://packagist.org/downloads/", 533 | "license": [ 534 | "BSD-3-Clause" 535 | ], 536 | "authors": [ 537 | { 538 | "name": "Jeff Welch", 539 | "email": "whatthejeff@gmail.com" 540 | }, 541 | { 542 | "name": "Volker Dusch", 543 | "email": "github@wallbash.com" 544 | }, 545 | { 546 | "name": "Bernhard Schussek", 547 | "email": "bschussek@2bepublished.at" 548 | }, 549 | { 550 | "name": "Sebastian Bergmann", 551 | "email": "sebastian@phpunit.de" 552 | } 553 | ], 554 | "description": "Provides the functionality to compare PHP values for equality", 555 | "homepage": "http://www.github.com/sebastianbergmann/comparator", 556 | "keywords": [ 557 | "comparator", 558 | "compare", 559 | "equality" 560 | ], 561 | "time": "2014-11-16 21:32:38" 562 | }, 563 | { 564 | "name": "sebastian/diff", 565 | "version": "1.2.0", 566 | "source": { 567 | "type": "git", 568 | "url": "https://github.com/sebastianbergmann/diff.git", 569 | "reference": "5843509fed39dee4b356a306401e9dd1a931fec7" 570 | }, 571 | "dist": { 572 | "type": "zip", 573 | "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/5843509fed39dee4b356a306401e9dd1a931fec7", 574 | "reference": "5843509fed39dee4b356a306401e9dd1a931fec7", 575 | "shasum": "" 576 | }, 577 | "require": { 578 | "php": ">=5.3.3" 579 | }, 580 | "require-dev": { 581 | "phpunit/phpunit": "~4.2" 582 | }, 583 | "type": "library", 584 | "extra": { 585 | "branch-alias": { 586 | "dev-master": "1.2-dev" 587 | } 588 | }, 589 | "autoload": { 590 | "classmap": [ 591 | "src/" 592 | ] 593 | }, 594 | "notification-url": "https://packagist.org/downloads/", 595 | "license": [ 596 | "BSD-3-Clause" 597 | ], 598 | "authors": [ 599 | { 600 | "name": "Kore Nordmann", 601 | "email": "mail@kore-nordmann.de" 602 | }, 603 | { 604 | "name": "Sebastian Bergmann", 605 | "email": "sebastian@phpunit.de" 606 | } 607 | ], 608 | "description": "Diff implementation", 609 | "homepage": "http://www.github.com/sebastianbergmann/diff", 610 | "keywords": [ 611 | "diff" 612 | ], 613 | "time": "2014-08-15 10:29:00" 614 | }, 615 | { 616 | "name": "sebastian/environment", 617 | "version": "1.2.1", 618 | "source": { 619 | "type": "git", 620 | "url": "https://github.com/sebastianbergmann/environment.git", 621 | "reference": "6e6c71d918088c251b181ba8b3088af4ac336dd7" 622 | }, 623 | "dist": { 624 | "type": "zip", 625 | "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6e6c71d918088c251b181ba8b3088af4ac336dd7", 626 | "reference": "6e6c71d918088c251b181ba8b3088af4ac336dd7", 627 | "shasum": "" 628 | }, 629 | "require": { 630 | "php": ">=5.3.3" 631 | }, 632 | "require-dev": { 633 | "phpunit/phpunit": "~4.3" 634 | }, 635 | "type": "library", 636 | "extra": { 637 | "branch-alias": { 638 | "dev-master": "1.2.x-dev" 639 | } 640 | }, 641 | "autoload": { 642 | "classmap": [ 643 | "src/" 644 | ] 645 | }, 646 | "notification-url": "https://packagist.org/downloads/", 647 | "license": [ 648 | "BSD-3-Clause" 649 | ], 650 | "authors": [ 651 | { 652 | "name": "Sebastian Bergmann", 653 | "email": "sebastian@phpunit.de" 654 | } 655 | ], 656 | "description": "Provides functionality to handle HHVM/PHP environments", 657 | "homepage": "http://www.github.com/sebastianbergmann/environment", 658 | "keywords": [ 659 | "Xdebug", 660 | "environment", 661 | "hhvm" 662 | ], 663 | "time": "2014-10-25 08:00:45" 664 | }, 665 | { 666 | "name": "sebastian/exporter", 667 | "version": "1.0.2", 668 | "source": { 669 | "type": "git", 670 | "url": "https://github.com/sebastianbergmann/exporter.git", 671 | "reference": "c7d59948d6e82818e1bdff7cadb6c34710eb7dc0" 672 | }, 673 | "dist": { 674 | "type": "zip", 675 | "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c7d59948d6e82818e1bdff7cadb6c34710eb7dc0", 676 | "reference": "c7d59948d6e82818e1bdff7cadb6c34710eb7dc0", 677 | "shasum": "" 678 | }, 679 | "require": { 680 | "php": ">=5.3.3" 681 | }, 682 | "require-dev": { 683 | "phpunit/phpunit": "~4.0" 684 | }, 685 | "type": "library", 686 | "extra": { 687 | "branch-alias": { 688 | "dev-master": "1.0.x-dev" 689 | } 690 | }, 691 | "autoload": { 692 | "classmap": [ 693 | "src/" 694 | ] 695 | }, 696 | "notification-url": "https://packagist.org/downloads/", 697 | "license": [ 698 | "BSD-3-Clause" 699 | ], 700 | "authors": [ 701 | { 702 | "name": "Jeff Welch", 703 | "email": "whatthejeff@gmail.com" 704 | }, 705 | { 706 | "name": "Volker Dusch", 707 | "email": "github@wallbash.com" 708 | }, 709 | { 710 | "name": "Bernhard Schussek", 711 | "email": "bschussek@2bepublished.at" 712 | }, 713 | { 714 | "name": "Sebastian Bergmann", 715 | "email": "sebastian@phpunit.de" 716 | }, 717 | { 718 | "name": "Adam Harvey", 719 | "email": "aharvey@php.net" 720 | } 721 | ], 722 | "description": "Provides the functionality to export PHP variables for visualization", 723 | "homepage": "http://www.github.com/sebastianbergmann/exporter", 724 | "keywords": [ 725 | "export", 726 | "exporter" 727 | ], 728 | "time": "2014-09-10 00:51:36" 729 | }, 730 | { 731 | "name": "sebastian/version", 732 | "version": "1.0.4", 733 | "source": { 734 | "type": "git", 735 | "url": "https://github.com/sebastianbergmann/version.git", 736 | "reference": "a77d9123f8e809db3fbdea15038c27a95da4058b" 737 | }, 738 | "dist": { 739 | "type": "zip", 740 | "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/a77d9123f8e809db3fbdea15038c27a95da4058b", 741 | "reference": "a77d9123f8e809db3fbdea15038c27a95da4058b", 742 | "shasum": "" 743 | }, 744 | "type": "library", 745 | "autoload": { 746 | "classmap": [ 747 | "src/" 748 | ] 749 | }, 750 | "notification-url": "https://packagist.org/downloads/", 751 | "license": [ 752 | "BSD-3-Clause" 753 | ], 754 | "authors": [ 755 | { 756 | "name": "Sebastian Bergmann", 757 | "email": "sebastian@phpunit.de", 758 | "role": "lead" 759 | } 760 | ], 761 | "description": "Library that helps with managing the version number of Git-hosted PHP projects", 762 | "homepage": "https://github.com/sebastianbergmann/version", 763 | "time": "2014-12-15 14:25:24" 764 | }, 765 | { 766 | "name": "symfony/yaml", 767 | "version": "v2.6.1", 768 | "target-dir": "Symfony/Component/Yaml", 769 | "source": { 770 | "type": "git", 771 | "url": "https://github.com/symfony/Yaml.git", 772 | "reference": "3346fc090a3eb6b53d408db2903b241af51dcb20" 773 | }, 774 | "dist": { 775 | "type": "zip", 776 | "url": "https://api.github.com/repos/symfony/Yaml/zipball/3346fc090a3eb6b53d408db2903b241af51dcb20", 777 | "reference": "3346fc090a3eb6b53d408db2903b241af51dcb20", 778 | "shasum": "" 779 | }, 780 | "require": { 781 | "php": ">=5.3.3" 782 | }, 783 | "type": "library", 784 | "extra": { 785 | "branch-alias": { 786 | "dev-master": "2.6-dev" 787 | } 788 | }, 789 | "autoload": { 790 | "psr-0": { 791 | "Symfony\\Component\\Yaml\\": "" 792 | } 793 | }, 794 | "notification-url": "https://packagist.org/downloads/", 795 | "license": [ 796 | "MIT" 797 | ], 798 | "authors": [ 799 | { 800 | "name": "Symfony Community", 801 | "homepage": "http://symfony.com/contributors" 802 | }, 803 | { 804 | "name": "Fabien Potencier", 805 | "email": "fabien@symfony.com" 806 | } 807 | ], 808 | "description": "Symfony Yaml Component", 809 | "homepage": "http://symfony.com", 810 | "time": "2014-12-02 20:19:20" 811 | } 812 | ], 813 | "aliases": [], 814 | "minimum-stability": "stable", 815 | "stability-flags": [], 816 | "prefer-stable": false, 817 | "prefer-lowest": false, 818 | "platform": { 819 | "php": ">=5.4.0" 820 | }, 821 | "platform-dev": [] 822 | } 823 | --------------------------------------------------------------------------------