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                 ( !in_array( $country, $campaign['countries'] ) && // Country wide
66                   !in_array( $uniqueRegionCode, $campaign['regions'] ) ) // Region
67            ) {
68                continue;
69            }
70
71            // Now filter by banner logged-in status and device
72            foreach ( $campaign['banners'] as $banner ) {
73                // Logged-in status
74                if ( $status === self::ANONYMOUS &&
75                    !$banner['display_anon']
76                ) {
77                    continue;
78                }
79                if ( $status === self::LOGGED_IN &&
80                    !$banner['display_account']
81                ) {
82                    continue;
83                }
84
85                // Device
86                if ( !in_array( $device, $banner['devices'] ) ) {
87                    continue;
88                }
89
90                // We get here if the campaign targets the requested country,
91                // and has at least one banner for the requested logged-in status
92                // and device.
93                $keepCampaign = true;
94                break;
95            }
96
97            if ( $keepCampaign ) {
98                $availableCampaigns[] = $campaign;
99            }
100        }
101        $choiceData = $availableCampaigns;
102    }
103
104    /**
105     * On $filteredChoiceData calculate the probability that the user has
106     * of receiving each campaign in this.choiceData. This takes into account
107     * campaign priority and throttling. The equivalent client-side method
108     * is mw.cnBannerControllerLib.calculateCampaignAllocations().
109     *
110     * @param array[] &$filteredChoiceData Data in the format provided by
111     *   filteredChoiceData().
112     */
113    public static function calculateCampaignAllocations( &$filteredChoiceData ) {
114        // Make an index of campaigns by priority level.
115        // Note that the actual values of priority levels are integers,
116        // and higher integers represent higher priority. These values are
117        // defined by class constants in CentralNotice.
118
119        $campaignsByPriority = [];
120        foreach ( $filteredChoiceData as &$campaign ) {
121            $priority = $campaign['preferred'];
122            $campaignsByPriority[$priority][] = &$campaign;
123        }
124
125        // Sort the index by priority, in descending order
126        krsort( $campaignsByPriority );
127
128        // Now go through the priority levels from highest to lowest. If
129        // campaigns are not throttled, then campaigns with a higher
130        // priority level will eclipse all campaigns with lower priority.
131        // Only if some campaigns are throttled will they allow some space
132        // for campaigns at the next level down.
133
134        $remainingAllocation = 1;
135
136        foreach ( $campaignsByPriority as $priority => &$campaignsAtThisPriority ) {
137            // If we fully allocated at a previous level, set allocations
138            // at this level to zero. (We check with 0.01 instead of 0 in
139            // case of issues due to finite precision.)
140            if ( $remainingAllocation < 0.01 ) {
141                foreach ( $campaignsAtThisPriority as &$campaign ) {
142                    $campaign['allocation'] = 0;
143                }
144                continue;
145            }
146
147            // If we are here, there is some allocation remaining.
148
149            // All campaigns at a given priority level are alloted the same
150            // allocation, unless they are throttled, in which case the
151            // throttling value (taken as a percentage of the whole
152            // allocation pie) is their maximum possible allocation.
153
154            // To calculate this, we'll loop through the campaigns at this
155            // level in order from the most throttled (lowest throttling
156            // value) to the least throttled (highest value) and on each
157            // loop, we'll re-calculate the remaining total allocation and
158            // the proportional (i.e. unthrottled) allocation available to
159            // each campaign.
160
161            // First, sort the campaigns by throttling value (ascending)
162
163            usort( $campaignsAtThisPriority, static function ( $a, $b ) {
164                if ( $a['throttle'] < $b['throttle'] ) {
165                    return -1;
166                }
167                if ( $a['throttle'] > $b['throttle'] ) {
168                    return 1;
169                }
170                return 0;
171            } );
172
173            $campaignsAtThisPriorityCount = count( $campaignsAtThisPriority );
174            foreach ( $campaignsAtThisPriority as $i => &$campaign ) {
175                // Calculate the proportional, unthrottled allocation now
176                // available to a campaign at this level.
177                $currentFullAllocation =
178                    $remainingAllocation / ( $campaignsAtThisPriorityCount - $i );
179
180                // A campaign may get the above amount, or less, if
181                // throttling indicates that'd be too much.
182                $actualAllocation =
183                    min( $currentFullAllocation, $campaign['throttle'] / 100 );
184
185                $campaign['allocation'] = $actualAllocation;
186
187                // Update remaining allocation
188                $remainingAllocation -= $actualAllocation;
189            }
190        }
191    }
192
193    /**
194     * Filter banners for $campaign on $bucket, $status and $device, and return
195     * a list of possible banners for this context. The equivalent client-side method
196     * is mw.cnBannerControllerLib.makePossibleBanners().
197     *
198     * @param array $campaign Campaign data in the format of a single entry
199     *   in the array provided by filteredChoiceData().
200     *
201     * @param int $bucket Bucket of interest
202     *
203     * @param int $status A status constant defined by this class (i.e.,
204     *   AllocationCalculator::ANONYMOUS or
205     *   AllocationCalculator::LOGGED_IN).
206     *
207     * @param string $device target device code
208     *
209     * @return array[] Array of banner information as arrays
210     */
211    public static function makePossibleBanners( $campaign, $bucket, $status, $device ) {
212        $banners = [];
213
214        foreach ( $campaign['banners'] as $banner ) {
215            // Filter for bucket
216            if ( $bucket % $campaign['bucket_count'] != $banner['bucket'] ) {
217                continue;
218            }
219
220            // Filter for logged-in status
221            if ( $status === self::ANONYMOUS &&
222                !$banner['display_anon']
223            ) {
224                continue;
225            }
226            if ( $status === self::LOGGED_IN &&
227                !$banner['display_account']
228            ) {
229                continue;
230            }
231
232            // Filter for device
233            if ( !in_array( $device, $banner['devices'] ) ) {
234                continue;
235            }
236
237            $banners[] = $banner;
238        }
239
240        return $banners;
241    }
242
243    /**
244     * Calculate the allocation of banners in a single campaign, based on
245     * relative weights. The equivalent client-side method is
246     * mw.cnBannerControllerLib.calculateBannerAllocations().
247     *
248     * @param array[] &$banners array of banner information as arrays.
249     *  Each banner's 'allocation' property will be set.
250     */
251    public static function calculateBannerAllocations( &$banners ) {
252        $totalWeights = 0;
253
254        // Find the sum of all banner weights
255        foreach ( $banners as $banner ) {
256            $totalWeights += $banner['weight'];
257        }
258
259        // Set allocation property to the normalized weight
260        foreach ( $banners as &$banner ) {
261            $banner['allocation'] = $banner['weight'] / $totalWeights;
262        }
263    }
264
265    /**
266     * Provide a list of allocated banners from a list of campaigns, filtering
267     * on the criteria provided.
268     *
269     * @param string $country Country of interest
270     *
271     * @param string $region Region of interest
272     *
273     * @param int $status A status constant defined by this class (i.e.,
274     *   AllocationCalculator::ANONYMOUS or
275     *   AllocationCalculator::LOGGED_IN).
276     *
277     * @param string $device target device code
278     *
279     * @param int $bucket Bucket of interest
280     *
281     * @param array $campaigns Campaigns with banners as returned by
282     *   ChoiceDataProvider::getChoices() or
283     *   Campaign::getHistoricalCampaigns
284     *
285     * @return array
286     */
287    public static function filterAndAllocate(
288        $country, $region, $status, $device, $bucket, $campaigns
289    ) {
290        // Filter and determine campaign allocation
291        self::makeAvailableCampaigns(
292            $campaigns,
293            $country,
294            $region,
295            $status,
296            $device
297        );
298
299        self::calculateCampaignAllocations( $campaigns );
300
301        // Go through all campaings to make a flat list of banners from all of
302        // them, and calculate overall relative allocations.
303        $possibleBannersAllCampaigns = [];
304        foreach ( $campaigns as $campaign ) {
305            $possibleBanners = self::makePossibleBanners(
306                $campaign,
307                $bucket,
308                $status,
309                $device
310            );
311
312            self::calculateBannerAllocations( $possibleBanners );
313
314            foreach ( $possibleBanners as $banner ) {
315                $banner['campaign'] = $campaign['name'];
316
317                $banner['allocation'] *= $campaign['allocation'];
318
319                $possibleBannersAllCampaigns[] = $banner;
320            }
321        }
322
323        return $possibleBannersAllCampaigns;
324    }
325
326    /**
327     * @param string $s
328     * @return int
329     * @throws InvalidArgumentException
330     */
331    public static function getLoggedInStatusFromString( $s ) {
332        switch ( $s ) {
333            case 'anonymous':
334                return self::ANONYMOUS;
335            case 'logged_in':
336                return self::LOGGED_IN;
337            default:
338                throw new InvalidArgumentException( 'Invalid logged-in status.' );
339        }
340    }
341}