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
20.45
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
5
 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 * @license GPL-2.0-or-later
14 */
15class 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}