%PDF- %PDF-
Direktori : /www/varak.net/wiki.varak.net/extensions/CirrusSearch/includes/ |
Current File : /www/varak.net/wiki.varak.net/extensions/CirrusSearch/includes/UserTesting.php |
<?php namespace CirrusSearch; /** * Handles decisions around if the current request is a member of any * test currently being run. This initial implementation is per-request * with a consistent bucketing scheme that keeps the user in the same * test over multiple requests when no trigger is provided. * * $wgCirrusSearchUserTesting = array( * 'someTest' => array( * 'sampleRate' => 100, // sample 1 in 100 occurrences * 'buckets' => array( * 'a' => array( * // control bucket, retain defaults * ), * 'b' => array( * 'globals' => array( * 'wgCirrusSearchBoostLinks' => 42, * ), * ), * ... * ), * , * ... * ); * * Per test configuration options: * * * sampleRate - A number >= 1 that specifies the sampling rate of the tests. * 1 in sampleRate requests will participate in the test. If a trigger * is provided by the request this will not be checked. A sampleRate of * 0 can be provided to allow only triggered inclusion. * * globals - A map from global variable name to value to set for all requests * participating in the test. * * buckets - A map from bucket name to bucket configuration. * * Per bucket configuration options: * * * globals - A map from global variable name to value to set for all requests * in this bucket. Per-bucket globals override per-test globals. * * trigger - Provide this value to allow inclusion in the bucket based on the * cirrusUserTesting query parameter. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * http://www.gnu.org/copyleft/gpl.html */ class UserTesting { /** * @var UserTesting|null Shared instance of this class configured with * $wgCirrusSearchUserTesting */ private static $instance; /** * @var string[] Map from test name to the bucket the request is in. */ protected $tests = []; /** * Returns a stable instance based on $wgCirrusSearchUserTesting * global configuration and the trigger from the main request context. * * @param callable|null $callback * @return self */ public static function getInstance( $callback = null, $trigger = null ) { global $wgCirrusSearchUserTesting; if ( self::$instance === null ) { if ( $trigger === null ) { $trigger = \RequestContext::getMain()->getRequest() ->getVal( 'cirrusUserTesting' ); } self::$instance = new self( $wgCirrusSearchUserTesting, $callback, $trigger ); } return self::$instance; } /** * Unit test only */ public static function resetInstance() { self::$instance = null; } /** * @param array $config * @param callable|null $callback * @param string|null $trigger Value to manually trigger a test. */ public function __construct( array $config, $callback = null, $trigger = '' ) { /** @suppress PhanTypeComparisonFromArray phan is just wrong here */ if ( $callback === null ) { $callback = [ __CLASS__, 'oneIn' ]; } foreach ( $config as $testName => $testConfig ) { if ( $trigger ) { foreach ( $testConfig['buckets'] as $bucket => $bucketConfig ) { if ( isset( $bucketConfig['trigger'] ) && $bucketConfig['trigger'] === $trigger ) { $this->activateTest( $testName, $bucket, $testConfig ); break; } } } elseif ( $testConfig['sampleRate'] > 0 ) { $bucketProbability = call_user_func( $callback, $testName, $testConfig['sampleRate'] ); if ( $bucketProbability > 0 ) { $this->activateTest( $testName, $bucketProbability, $testConfig ); } } } } /** * @param string $testName Name of a test being run * @return bool True when the request is participating in the named test */ public function isParticipatingIn( $testName ) { return isset( $this->tests[$testName] ); } /** * @param string $testName Name of a test being run * @return string The bucket the request has been placed in for the named * test. If the request is not participating in the test the bucket will * be the empty string. */ public function getBucket( $testName ) { return isset( $this->tests[$testName] ) ? $this->tests[$testName] : ''; } /** * @return string[] List of tests that are active for the current request. */ public function getActiveTestNames() { return array_keys( $this->tests ); } /** * @return string[] */ public function getActiveTestNamesWithBucket() { $result = []; foreach ( $this->tests as $test => $bucket ) { $result[] = "$test:$bucket"; } return $result; } /** * @param string $testName Name of the test to activate. * @param float|string $bucket Either the name of a bucket, or a number * between 0 and 1 for determining bucket. * @param array $testConfig Configuration of the test to activate. */ protected function activateTest( $testName, $bucket, array $testConfig ) { $this->tests[$testName] = ''; $globals = []; if ( isset( $testConfig['globals'] ) ) { $globals = $testConfig['globals']; } if ( isset( $testConfig['buckets'] ) ) { if ( !is_string( $bucket ) ) { $bucket = $this->chooseBucket( $bucket, array_keys( $testConfig['buckets'] ) ); } $this->tests[$testName] = $bucket; if ( isset( $testConfig['buckets'][$bucket]['globals'] ) ) { $globals = array_merge( $globals, $testConfig['buckets'][$bucket]['globals'] ); } } foreach ( $globals as $key => $value ) { if ( array_key_exists( $key, $GLOBALS ) ) { $GLOBALS[$key] = $value; } } } /** * @param float $probability A number between 0 and 1 * @param string[] $buckets List of buckets to choose from. * @return string The chosen bucket. */ static public function chooseBucket( $probability, $buckets ) { $num = count( $buckets ); $each = 1 / $num; $current = 0; foreach ( $buckets as $bucket ) { $current += $each; if ( $current >= $probability ) { return $bucket; } } // >= should ensure we never get here, // unless probability > 1 return end( $buckets ); } /** * Converts a hex string into a probability between 0 and 1. * Retains uniform distribution of incoming hash string. * * @param string $hash * @return float Probability between 0 and 1 */ static public function hexToProbability( $hash ) { if ( strlen( $hash ) === 0 ) { throw new \RuntimeException( 'Empty hash provided' ); } $len = strlen( $hash ); $sum = 0; for ( $i = 0; $i < $len; $i += 4) { $piece = substr( $hash, $i, 4 ); $dec = hexdec( $piece ); // xor will retain the uniform distribution $sum = $sum ^ $dec; } return $sum / ((1<<16)-1); } /** * @param string $testName * @param int $sampleRate * @return float for 1 in $sampleRate calls to this method * returns a stable probability between 0 and 1. for all other * requests returns 0. */ static public function oneIn( $testName, $sampleRate ) { $hash = ElasticsearchIntermediary::generateIdentToken( $testName ); $probability = self::hexToProbability( $hash ); $rateThreshold = 1 / $sampleRate; if ( $rateThreshold >= $probability ) { return $probability / $rateThreshold; } return 0; } }