Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
89.58% |
43 / 48 |
|
88.89% |
8 / 9 |
CRAP | |
0.00% |
0 / 1 |
| UserTestingEngine | |
89.58% |
43 / 48 |
|
88.89% |
8 / 9 |
20.45 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| fromConfig | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| decideTestByTrigger | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| decideTestByAutoenroll | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| decideActiveTest | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
| activateTest | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
5 | |||
| chooseBucket | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| hexToProbability | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
| stableUserProbability | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace CirrusSearch; |
| 4 | |
| 5 | use MediaWiki\Config\Config; |
| 6 | use Wikimedia\Assert\Assert; |
| 7 | |
| 8 | /** |
| 9 | * Decision making around user testing |
| 10 | * |
| 11 | * See docs/user_testing.md for more information. |
| 12 | * |
| 13 | * @license GPL-2.0-or-later |
| 14 | */ |
| 15 | class UserTestingEngine { |
| 16 | /** @var array */ |
| 17 | private $tests; |
| 18 | |
| 19 | /** @var ?string */ |
| 20 | private $activeTest; |
| 21 | |
| 22 | /** |
| 23 | * @var callable Called with the test name, returns a float between 0 and 1 |
| 24 | * which is uniformly distributed across users and stable for an individual |
| 25 | * user+testName combination. |
| 26 | */ |
| 27 | private $callback; |
| 28 | |
| 29 | /** |
| 30 | * @param array $tests |
| 31 | * @param ?string $activeTest Array key in test to enable autoenroll for, or null |
| 32 | * for no autoenrollment. |
| 33 | * @param callable $callback Called with the test name, returns a float |
| 34 | * between 0 and 1 which is uniformly distributed across users and stable |
| 35 | * for an individual user+testName combination. |
| 36 | */ |
| 37 | public function __construct( array $tests, ?string $activeTest, callable $callback ) { |
| 38 | $this->tests = $tests; |
| 39 | $this->activeTest = $activeTest; |
| 40 | $this->callback = $callback; |
| 41 | } |
| 42 | |
| 43 | public static function fromConfig( Config $config ): UserTestingEngine { |
| 44 | return new self( |
| 45 | // While we shouldn't get null in normal operations, the global |
| 46 | // initialization of user testing is a bit sloppy and this gets |
| 47 | // invoked during ElasticsearchIntermediary unit testing, and unit |
| 48 | // testing doesn't have any globally accessible config. |
| 49 | $config->get( CirrusConfigNames::UserTesting ) ?? [], |
| 50 | $config->get( CirrusConfigNames::ActiveTest ), |
| 51 | self::stableUserProbability( ... ) |
| 52 | ); |
| 53 | } |
| 54 | |
| 55 | public function decideTestByTrigger( string $trigger ): UserTestingStatus { |
| 56 | if ( strpos( $trigger, ':' ) === false ) { |
| 57 | return UserTestingStatus::inactive(); |
| 58 | } |
| 59 | [ $testName, $bucket ] = explode( ':', $trigger, 2 ); |
| 60 | if ( isset( $this->tests[$testName]['buckets'][$bucket] ) ) { |
| 61 | return UserTestingStatus::active( $testName, $bucket ); |
| 62 | } else { |
| 63 | return UserTestingStatus::inactive(); |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | public function decideTestByAutoenroll(): UserTestingStatus { |
| 68 | if ( $this->activeTest === null || !isset( $this->tests[$this->activeTest] ) ) { |
| 69 | return UserTestingStatus::inactive(); |
| 70 | } |
| 71 | $bucketProbability = ( $this->callback )( $this->activeTest ); |
| 72 | $bucket = self::chooseBucket( $bucketProbability, array_keys( |
| 73 | $this->tests[$this->activeTest]['buckets'] ) ); |
| 74 | return UserTestingStatus::active( $this->activeTest, $bucket ); |
| 75 | } |
| 76 | |
| 77 | public function decideActiveTest( ?string $trigger ): UserTestingStatus { |
| 78 | if ( $trigger !== null ) { |
| 79 | return $this->decideTestByTrigger( $trigger ); |
| 80 | } elseif ( MW_ENTRY_POINT == 'index' ) { |
| 81 | return $this->decideTestByAutoenroll(); |
| 82 | } else { |
| 83 | return UserTestingStatus::inactive(); |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | /** |
| 88 | * If provided status is in an active state enable the related configuration. |
| 89 | */ |
| 90 | public function activateTest( UserTestingStatus $status ) { |
| 91 | if ( !$status->isActive() ) { |
| 92 | return; |
| 93 | } |
| 94 | // boldly assume we created this status and it exists |
| 95 | $testConfig = $this->tests[$status->getTestName()]; |
| 96 | $globals = array_merge( |
| 97 | $testConfig['globals'] ?? [], |
| 98 | $testConfig['buckets'][$status->getBucket()]['globals'] ?? [] ); |
| 99 | |
| 100 | foreach ( $globals as $key => $value ) { |
| 101 | // (T317951) Don't call array_key_exists unless we have to, as it's slow |
| 102 | // on PHP 8.1+ for $GLOBALS. When the key is set but is explicitly set |
| 103 | // to null, we still need to fall back to array_key_exists, but that's |
| 104 | // rarer. |
| 105 | if ( isset( $GLOBALS[$key] ) || array_key_exists( $key, $GLOBALS ) ) { |
| 106 | $GLOBALS[$key] = $value; |
| 107 | } |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | /** |
| 112 | * @param float $probability A number between 0 and 1 |
| 113 | * @param string[] $buckets List of buckets to choose from. |
| 114 | * @return string The chosen bucket. |
| 115 | */ |
| 116 | public static function chooseBucket( float $probability, array $buckets ): string { |
| 117 | $n = count( $buckets ); |
| 118 | $pos = (int)min( $n - 1, $n * $probability ); |
| 119 | return $buckets[ $pos ]; |
| 120 | } |
| 121 | |
| 122 | /** |
| 123 | * Converts a hex string into a probability between 0 and 1. |
| 124 | * Retains uniform distribution of incoming hash string. |
| 125 | * |
| 126 | * @param string $hash |
| 127 | * @return float Probability between 0 and 1 |
| 128 | */ |
| 129 | public static function hexToProbability( string $hash ): float { |
| 130 | Assert::parameter( strlen( $hash ) > 0, '$hash', |
| 131 | 'Provided string must not be empty' ); |
| 132 | $len = strlen( $hash ); |
| 133 | $sum = 0; |
| 134 | // TODO: Since the input is from a cryptographic hash simply |
| 135 | // truncating is probably equally correct. |
| 136 | for ( $i = 0; $i < $len; $i += 4 ) { |
| 137 | $piece = substr( $hash, $i, 4 ); |
| 138 | $dec = hexdec( $piece ); |
| 139 | // xor will retain the uniform distribution |
| 140 | $sum ^= $dec; |
| 141 | } |
| 142 | return $sum / ( ( 1 << 16 ) - 1 ); |
| 143 | } |
| 144 | |
| 145 | /** |
| 146 | * @param string $testName |
| 147 | * @return float Returns a value between 0 and 1 that is uniformly |
| 148 | * distributed between users, but constant for a single user+test |
| 149 | * combination. |
| 150 | */ |
| 151 | public static function stableUserProbability( string $testName ): float { |
| 152 | $hash = Util::generateIdentToken( $testName ); |
| 153 | return self::hexToProbability( $hash ); |
| 154 | } |
| 155 | } |