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 | ( |
| 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 | } |