Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.58% covered (warning)
89.58%
43 / 48
88.89% covered (warning)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserTestingEngine
89.58% covered (warning)
89.58%
43 / 48
88.89% covered (warning)
88.89%
8 / 9
19.41
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 fromConfig
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 decideTestByTrigger
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 decideTestByAutoenroll
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 decideActiveTest
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 activateTest
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 chooseBucket
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 hexToProbability
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 stableUserProbability
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace CirrusSearch;
4
5use MediaWiki\Config\Config;
6use 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 */
28class 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            if ( array_key_exists( $key, $GLOBALS ) ) {
117                $GLOBALS[$key] = $value;
118            }
119        }
120    }
121
122    /**
123     * @param float $probability A number between 0 and 1
124     * @param string[] $buckets List of buckets to choose from.
125     * @return string The chosen bucket.
126     */
127    public static function chooseBucket( float $probability, array $buckets ): string {
128        $n = count( $buckets );
129        $pos = (int)min( $n - 1, $n * $probability );
130        return $buckets[ $pos ];
131    }
132
133    /**
134     * Converts a hex string into a probability between 0 and 1.
135     * Retains uniform distribution of incoming hash string.
136     *
137     * @param string $hash
138     * @return float Probability between 0 and 1
139     */
140    public static function hexToProbability( string $hash ): float {
141        Assert::parameter( strlen( $hash ) > 0, '$hash',
142            'Provided string must not be empty' );
143        $len = strlen( $hash );
144        $sum = 0;
145        // TODO: Since the input is from a cryptographic hash simply
146        // truncating is probably equally correct.
147        for ( $i = 0; $i < $len; $i += 4 ) {
148            $piece = substr( $hash, $i, 4 );
149            $dec = hexdec( $piece );
150            // xor will retain the uniform distribution
151            $sum ^= $dec;
152        }
153        return $sum / ( ( 1 << 16 ) - 1 );
154    }
155
156    /**
157     * @param string $testName
158     * @return float Returns a value between 0 and 1 that is uniformly
159     *  distributed between users, but constant for a single user+test
160     *  combination.
161     */
162    public static function stableUserProbability( string $testName ): float {
163        $hash = Util::generateIdentToken( $testName );
164        return self::hexToProbability( $hash );
165    }
166}