Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
PageSplitterInstrumentation
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
4 / 4
6
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 isSampled
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getBucket
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 scaledHash
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace WikimediaEvents\PageSplitter;
4
5use Wikimedia\Assert\Assert;
6
7/**
8 * Deterministic sampling and bucketing based on a page IDs.
9 *
10 * The caller takes care of turning a page ID into a deterministic hash with
11 * uniform probability distribution (see PageHashGenerate).
12 *
13 * Given an example page that is assigned 0.421 and 3 buckets (A, B, C), it works as follows:
14 *
15 * - The assigned float is scaled to cover the three buckets, in #scaledHash().
16 *   0.421 * 3 = 1.263
17 *
18 * - Each whole number represents a bucket. This case we're in bucket B.
19 *   A = 0.x, B = 1.x, C = 2.x
20 *
21 * - The fraction within each number represents the sampling, so if our sampling ratio
22 *   is 0.5, than x.00 to x.50 would be sampled, and x.50 to x.99 would be unsampled.
23 *   In this case we're 1.263 which is sampled, and in bucket B.
24 *
25 * @license GPL-2.0-or-later
26 */
27class PageSplitterInstrumentation {
28    /**
29     * @var float
30     */
31    private $samplingRatio;
32
33    /**
34     * @var array
35     */
36    private $buckets;
37
38    /**
39     * @param float $samplingRatio The sampling ratio, [0, 1].
40     * @param array $buckets An optional array of bucket name strings, e.g., `[ 'control', 'treatment' ]`.
41     */
42    public function __construct( float $samplingRatio, array $buckets ) {
43        Assert::parameter(
44            $samplingRatio >= 0 && $samplingRatio <= 1,
45            'samplingRatio',
46            'Sampling ratio must be in range [0, 1]'
47        );
48        $this->samplingRatio = $samplingRatio;
49        $this->buckets = $buckets;
50    }
51
52    /**
53     * Whether given page is in the sample.
54     *
55     * Should be called before getBucket().
56     *
57     * @param float $pageHash
58     * @return bool True if sampled, false if unsampled.
59     */
60    public function isSampled( float $pageHash ): bool {
61        // Take the right of the decimal.
62        $sample = fmod( $this->scaledHash( $pageHash ), 1 );
63        return $sample < $this->samplingRatio;
64    }
65
66    /**
67     * Which bucket a given page is in.
68     *
69     * This does NOT imply sampling and should usually be called after isSampled().
70     *
71     * @param float $pageHash
72     * @return string|null Bucket name or null if buckets are unused.
73     */
74    public function getBucket( float $pageHash ): ?string {
75        if ( $this->buckets === [] ) {
76            return null;
77        }
78
79        // Get the bucket index (int is akin to floor/truncate, but as int instead of float)
80        $index = (int)$this->scaledHash( $pageHash );
81        return $this->buckets[ $index ];
82    }
83
84    /**
85     * @param float $pageHash
86     * @return float Integer component is the bucket index (from 0 to count-1), fractional component is the sample rate.
87     */
88    private function scaledHash( float $pageHash ): float {
89        return $pageHash * max( 1, count( $this->buckets ) );
90    }
91
92}