Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.34% covered (warning)
72.34%
68 / 94
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
AllocationCalculator
72.34% covered (warning)
72.34%
68 / 94
50.00% covered (danger)
50.00%
3 / 6
68.56
0.00% covered (danger)
0.00%
0 / 1
 makeAvailableCampaigns
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
12
 calculateCampaignAllocations
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
8
 makePossibleBanners
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
8.63
 calculateBannerAllocations
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 filterAndAllocate
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 getLoggedInStatusFromString
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
1<?php
2/**
3 * Wikimedia Foundation
4 *
5 * LICENSE
6 *
7 * This program is free software; you can redistribute it and/or modify
8 * it under the terms of the GNU General Public License as published by
9 * the Free Software Foundation; either version 2 of the License, or
10 * (at your option) any later version.
11 *
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU General Public License for more details.
16 *
17 */
18
19/**
20 * Calculates banner and campaign allocation percentages for use in
21 * Special:BannerAllocation. The actual calculations used to decide
22 * which banner is shown are performed on the client. Most methods
23 * here closely mirror the client-side methods for that, found in
24 * ext.centralNotice.display.chooser.js (exposed in JS as
25 * cn.internal.chooser).
26 */
27class AllocationCalculator {
28
29    public const LOGGED_IN = 0;
30    public const ANONYMOUS = 1;
31
32    /**
33     * Filter an array in the format output by
34     * ChoiceDataProvider::getChoices(), based on country, logged-in
35     * status and device. This method is the server-side equivalent of
36     * mw.centralNotice.internal.chooser.makeAvailableCampaigns(). (However, this method does
37     * not perform campaign freshness checks like the client-side one.)
38     *
39     * @param array[] &$choiceData Campaigns with banners as returned by
40     *   ChoiceDataProvider::getChoices(). This array will be modified.
41     *
42     * @param string $country Country of interest
43     *
44     * @param string $region Region of interest
45     *
46     * @param int $status A status constant defined by this class (i.e.,
47     *   AllocationCalculator::ANONYMOUS or
48     *   AllocationCalculator::LOGGED_IN).
49     *
50     * @param string $device target device code
51     */
52    public static function makeAvailableCampaigns(
53        &$choiceData, $country, $region, $status, $device
54    ) {
55        $availableCampaigns = [];
56
57        foreach ( $choiceData as $campaign ) {
58            $keepCampaign = false;
59            $uniqueRegionCode = GeoTarget::makeUniqueRegionCode( $country, $region );
60
61            // Filter for country/region if geotargeted
62            // Note: the region from user context is prefixed with country code (eg.: RU_MOW)
63            // to avoid collision with similarly named regions across different countries
64            if ( $campaign['geotargeted'] &&
65                (
66                    // Country wide
67                    !in_array( $country, $campaign['countries'] ) &&
68                    // Region
69                    !in_array( $uniqueRegionCode, $campaign['regions'] )
70                )
71            ) {
72                continue;
73            }
74
75            // Now filter by banner logged-in status and device
76            foreach ( $campaign['banners'] as $banner ) {
77                // Logged-in status
78                if ( $status === self::ANONYMOUS &&
79                    !$banner['display_anon']
80                ) {
81                    continue;
82                }
83                if ( $status === self::LOGGED_IN &&
84                    !$banner['display_account']
85                ) {
86                    continue;
87                }
88
89                // Device
90                if ( !in_array( $device, $banner['devices'] ) ) {
91                    continue;
92                }
93
94                // We get here if the campaign targets the requested country,
95                // and has at least one banner for the requested logged-in status
96                // and device.
97                $keepCampaign = true;
98                break;
99            }
100
101            if ( $keepCampaign ) {
102                $availableCampaigns[] = $campaign;
103            }
104        }
105        $choiceData = $availableCampaigns;
106    }
107
108    /**
109     * On $filteredChoiceData calculate the probability that the user has
110     * of receiving each campaign in this.choiceData. This takes into account
111     * campaign priority and throttling. The equivalent client-side method
112     * is mw.cnBannerControllerLib.calculateCampaignAllocations().
113     *
114     * @param array[] &$filteredChoiceData Data in the format provided by
115     *   filteredChoiceData().
116     */
117    public static function calculateCampaignAllocations( &$filteredChoiceData ) {
118        // Make an index of campaigns by priority level.
119        // Note that the actual values of priority levels are integers,
120        // and higher integers represent higher priority. These values are
121        // defined by class constants in CentralNotice.
122
123        $campaignsByPriority = [];
124        foreach ( $filteredChoiceData as &$campaign ) {
125            $priority = $campaign['preferred'];
126            $campaignsByPriority[$priority][] = &$campaign;
127        }
128
129        // Sort the index by priority, in descending order
130        krsort( $campaignsByPriority );
131
132        // Now go through the priority levels from highest to lowest. If
133        // campaigns are not throttled, then campaigns with a higher
134        // priority level will eclipse all campaigns with lower priority.
135        // Only if some campaigns are throttled will they allow some space
136        // for campaigns at the next level down.
137
138        $remainingAllocation = 1;
139
140        foreach ( $campaignsByPriority as $priority => &$campaignsAtThisPriority ) {
141            // If we fully allocated at a previous level, set allocations
142            // at this level to zero. (We check with 0.01 instead of 0 in
143            // case of issues due to finite precision.)
144            if ( $remainingAllocation < 0.01 ) {
145                foreach ( $campaignsAtThisPriority as &$campaign ) {
146                    $campaign['allocation'] = 0;
147                }
148                continue;
149            }
150
151            // If we are here, there is some allocation remaining.
152
153            // All campaigns at a given priority level are alloted the same
154            // allocation, unless they are throttled, in which case the
155            // throttling value (taken as a percentage of the whole
156            // allocation pie) is their maximum possible allocation.
157
158            // To calculate this, we'll loop through the campaigns at this
159            // level in order from the most throttled (lowest throttling
160            // value) to the least throttled (highest value) and on each
161            // loop, we'll re-calculate the remaining total allocation and
162            // the proportional (i.e. unthrottled) allocation available to
163            // each campaign.
164
165            // First, sort the campaigns by throttling value (ascending)
166
167            usort( $campaignsAtThisPriority, static function ( $a, $b ) {
168                if ( $a['throttle'] < $b['throttle'] ) {
169                    return -1;
170                }
171                if ( $a['throttle'] > $b['throttle'] ) {
172                    return 1;
173                }
174                return 0;
175            } );
176
177            $campaignsAtThisPriorityCount = count( $campaignsAtThisPriority );
178            foreach ( $campaignsAtThisPriority as $i => &$campaign ) {
179                // Calculate the proportional, unthrottled allocation now
180                // available to a campaign at this level.
181                $currentFullAllocation =
182                    $remainingAllocation / ( $campaignsAtThisPriorityCount - $i );
183
184                // A campaign may get the above amount, or less, if
185                // throttling indicates that'd be too much.
186                $actualAllocation =
187                    min( $currentFullAllocation, $campaign['throttle'] / 100 );
188
189                $campaign['allocation'] = $actualAllocation;
190
191                // Update remaining allocation
192                $remainingAllocation -= $actualAllocation;
193            }
194        }
195    }
196
197    /**
198     * Filter banners for $campaign on $bucket, $status and $device, and return
199     * a list of possible banners for this context. The equivalent client-side method
200     * is mw.cnBannerControllerLib.makePossibleBanners().
201     *
202     * @param array $campaign Campaign data in the format of a single entry
203     *   in the array provided by filteredChoiceData().
204     *
205     * @param int $bucket Bucket of interest
206     *
207     * @param int $status A status constant defined by this class (i.e.,
208     *   AllocationCalculator::ANONYMOUS or
209     *   AllocationCalculator::LOGGED_IN).
210     *
211     * @param string $device target device code
212     *
213     * @return array[] Array of banner information as arrays
214     */
215    public static function makePossibleBanners( $campaign, $bucket, $status, $device ) {
216        $banners = [];
217
218        foreach ( $campaign['banners'] as $banner ) {
219            // Filter for bucket
220            if ( $bucket % $campaign['bucket_count'] != $banner['bucket'] ) {
221                continue;
222            }
223
224            // Filter for logged-in status
225            if ( $status === self::ANONYMOUS &&
226                !$banner['display_anon']
227            ) {
228                continue;
229            }
230            if ( $status === self::LOGGED_IN &&
231                !$banner['display_account']
232            ) {
233                continue;
234            }
235
236            // Filter for device
237            if ( !in_array( $device, $banner['devices'] ) ) {
238                continue;
239            }
240
241            $banners[] = $banner;
242        }
243
244        return $banners;
245    }
246
247    /**
248     * Calculate the allocation of banners in a single campaign, based on
249     * relative weights. The equivalent client-side method is
250     * mw.cnBannerControllerLib.calculateBannerAllocations().
251     *
252     * @param array[] &$banners array of banner information as arrays.
253     *  Each banner's 'allocation' property will be set.
254     */
255    public static function calculateBannerAllocations( &$banners ) {
256        $totalWeights = 0;
257
258        // Find the sum of all banner weights
259        foreach ( $banners as $banner ) {
260            $totalWeights += $banner['weight'];
261        }
262
263        // Set allocation property to the normalized weight
264        foreach ( $banners as &$banner ) {
265            $banner['allocation'] = $banner['weight'] / $totalWeights;
266        }
267    }
268
269    /**
270     * Provide a list of allocated banners from a list of campaigns, filtering
271     * on the criteria provided.
272     *
273     * @param string $country Country of interest
274     *
275     * @param string $region Region of interest
276     *
277     * @param int $status A status constant defined by this class (i.e.,
278     *   AllocationCalculator::ANONYMOUS or
279     *   AllocationCalculator::LOGGED_IN).
280     *
281     * @param string $device target device code
282     *
283     * @param int $bucket Bucket of interest
284     *
285     * @param array $campaigns Campaigns with banners as returned by
286     *   ChoiceDataProvider::getChoices() or
287     *   Campaign::getHistoricalCampaigns
288     *
289     * @return array
290     */
291    public static function filterAndAllocate(
292        $country, $region, $status, $device, $bucket, $campaigns
293    ) {
294        // Filter and determine campaign allocation
295        self::makeAvailableCampaigns(
296            $campaigns,
297            $country,
298            $region,
299            $status,
300            $device
301        );
302
303        self::calculateCampaignAllocations( $campaigns );
304
305        // Go through all campaings to make a flat list of banners from all of
306        // them, and calculate overall relative allocations.
307        $possibleBannersAllCampaigns = [];
308        foreach ( $campaigns as $campaign ) {
309            $possibleBanners = self::makePossibleBanners(
310                $campaign,
311                $bucket,
312                $status,
313                $device
314            );
315
316            self::calculateBannerAllocations( $possibleBanners );
317
318            foreach ( $possibleBanners as $banner ) {
319                $banner['campaign'] = $campaign['name'];
320
321                $banner['allocation'] *= $campaign['allocation'];
322
323                $possibleBannersAllCampaigns[] = $banner;
324            }
325        }
326
327        return $possibleBannersAllCampaigns;
328    }
329
330    /**
331     * @param string $s
332     * @return int
333     * @throws InvalidArgumentException
334     */
335    public static function getLoggedInStatusFromString( $s ) {
336        switch ( $s ) {
337            case 'anonymous':
338                return self::ANONYMOUS;
339            case 'logged_in':
340                return self::LOGGED_IN;
341            default:
342                throw new InvalidArgumentException( 'Invalid logged-in status.' );
343        }
344    }
345}