Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
72.34% |
68 / 94 |
|
50.00% |
3 / 6 |
CRAP | |
0.00% |
0 / 1 |
AllocationCalculator | |
72.34% |
68 / 94 |
|
50.00% |
3 / 6 |
68.56 | |
0.00% |
0 / 1 |
makeAvailableCampaigns | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
12 | |||
calculateCampaignAllocations | |
100.00% |
26 / 26 |
|
100.00% |
1 / 1 |
8 | |||
makePossibleBanners | |
78.57% |
11 / 14 |
|
0.00% |
0 / 1 |
8.63 | |||
calculateBannerAllocations | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
filterAndAllocate | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
12 | |||
getLoggedInStatusFromString | |
80.00% |
4 / 5 |
|
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 | */ |
27 | class 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 | } |