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 | * This program is free software; you can redistribute it and/or modify |
14 | * it under the terms of the GNU General Public License as published by |
15 | * the Free Software Foundation; either version 2 of the License, or |
16 | * (at your option) any later version. |
17 | * |
18 | * This program is distributed in the hope that it will be useful, |
19 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
20 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
21 | * GNU General Public License for more details. |
22 | * |
23 | * You should have received a copy of the GNU General Public License along |
24 | * with this program; if not, write to the Free Software Foundation, Inc., |
25 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
26 | * http://www.gnu.org/copyleft/gpl.html |
27 | */ |
28 | class UserTestingEngine { |
29 | /** @var array */ |
30 | private $tests; |
31 | |
32 | /** @var ?string */ |
33 | private $activeTest; |
34 | |
35 | /** |
36 | * @var callable Called with the test name, returns a float between 0 and 1 |
37 | * which is uniformly distributed across users and stable for an individual |
38 | * user+testName combination. |
39 | */ |
40 | private $callback; |
41 | |
42 | /** |
43 | * @param array $tests |
44 | * @param ?string $activeTest Array key in test to enable autoenroll for, or null |
45 | * for no autoenrollment. |
46 | * @param callable $callback Called with the test name, returns a float |
47 | * between 0 and 1 which is uniformly distributed across users and stable |
48 | * for an individual user+testName combination. |
49 | */ |
50 | public function __construct( array $tests, ?string $activeTest, callable $callback ) { |
51 | $this->tests = $tests; |
52 | $this->activeTest = $activeTest; |
53 | $this->callback = $callback; |
54 | } |
55 | |
56 | public static function fromConfig( Config $config ): UserTestingEngine { |
57 | return new self( |
58 | // While we shouldn't get null in normal operations, the global |
59 | // initialization of user testing is a bit sloppy and this gets |
60 | // invoked during ElasticsearchIntermediary unit testing, and unit |
61 | // testing doesn't have any globally accessible config. |
62 | $config->get( 'CirrusSearchUserTesting' ) ?? [], |
63 | $config->get( 'CirrusSearchActiveTest' ), |
64 | [ __CLASS__, 'stableUserProbability' ] |
65 | ); |
66 | } |
67 | |
68 | public function decideTestByTrigger( string $trigger ): UserTestingStatus { |
69 | if ( strpos( $trigger, ':' ) === false ) { |
70 | return UserTestingStatus::inactive(); |
71 | } |
72 | [ $testName, $bucket ] = explode( ':', $trigger, 2 ); |
73 | if ( isset( $this->tests[$testName]['buckets'][$bucket] ) ) { |
74 | return UserTestingStatus::active( $testName, $bucket ); |
75 | } else { |
76 | return UserTestingStatus::inactive(); |
77 | } |
78 | } |
79 | |
80 | public function decideTestByAutoenroll(): UserTestingStatus { |
81 | if ( $this->activeTest === null || !isset( $this->tests[$this->activeTest] ) ) { |
82 | return UserTestingStatus::inactive(); |
83 | } |
84 | $bucketProbability = call_user_func( $this->callback, $this->activeTest ); |
85 | $bucket = self::chooseBucket( $bucketProbability, array_keys( |
86 | $this->tests[$this->activeTest]['buckets'] ) ); |
87 | return UserTestingStatus::active( $this->activeTest, $bucket ); |
88 | } |
89 | |
90 | public function decideActiveTest( ?string $trigger ): UserTestingStatus { |
91 | if ( $trigger !== null ) { |
92 | return $this->decideTestByTrigger( $trigger ); |
93 | } elseif ( MW_ENTRY_POINT == 'index' ) { |
94 | return $this->decideTestByAutoenroll(); |
95 | } else { |
96 | return UserTestingStatus::inactive(); |
97 | } |
98 | } |
99 | |
100 | /** |
101 | * If provided status is in an active state enable the related configuration. |
102 | * |
103 | * @param UserTestingStatus $status |
104 | */ |
105 | public function activateTest( UserTestingStatus $status ) { |
106 | if ( !$status->isActive() ) { |
107 | return; |
108 | } |
109 | // boldly assume we created this status and it exists |
110 | $testConfig = $this->tests[$status->getTestName()]; |
111 | $globals = array_merge( |
112 | $testConfig['globals'] ?? [], |
113 | $testConfig['buckets'][$status->getBucket()]['globals'] ?? [] ); |
114 | |
115 | foreach ( $globals as $key => $value ) { |
116 | // (T317951) Don't call array_key_exists unless we have to, as it's slow |
117 | // on PHP 8.1+ for $GLOBALS. When the key is set but is explicitly set |
118 | // to null, we still need to fall back to array_key_exists, but that's |
119 | // rarer. |
120 | if ( isset( $GLOBALS[$key] ) || array_key_exists( $key, $GLOBALS ) ) { |
121 | $GLOBALS[$key] = $value; |
122 | } |
123 | } |
124 | } |
125 | |
126 | /** |
127 | * @param float $probability A number between 0 and 1 |
128 | * @param string[] $buckets List of buckets to choose from. |
129 | * @return string The chosen bucket. |
130 | */ |
131 | public static function chooseBucket( float $probability, array $buckets ): string { |
132 | $n = count( $buckets ); |
133 | $pos = (int)min( $n - 1, $n * $probability ); |
134 | return $buckets[ $pos ]; |
135 | } |
136 | |
137 | /** |
138 | * Converts a hex string into a probability between 0 and 1. |
139 | * Retains uniform distribution of incoming hash string. |
140 | * |
141 | * @param string $hash |
142 | * @return float Probability between 0 and 1 |
143 | */ |
144 | public static function hexToProbability( string $hash ): float { |
145 | Assert::parameter( strlen( $hash ) > 0, '$hash', |
146 | 'Provided string must not be empty' ); |
147 | $len = strlen( $hash ); |
148 | $sum = 0; |
149 | // TODO: Since the input is from a cryptographic hash simply |
150 | // truncating is probably equally correct. |
151 | for ( $i = 0; $i < $len; $i += 4 ) { |
152 | $piece = substr( $hash, $i, 4 ); |
153 | $dec = hexdec( $piece ); |
154 | // xor will retain the uniform distribution |
155 | $sum ^= $dec; |
156 | } |
157 | return $sum / ( ( 1 << 16 ) - 1 ); |
158 | } |
159 | |
160 | /** |
161 | * @param string $testName |
162 | * @return float Returns a value between 0 and 1 that is uniformly |
163 | * distributed between users, but constant for a single user+test |
164 | * combination. |
165 | */ |
166 | public static function stableUserProbability( string $testName ): float { |
167 | $hash = Util::generateIdentToken( $testName ); |
168 | return self::hexToProbability( $hash ); |
169 | } |
170 | } |