| 1 |
<?php |
| 2 |
/** |
| 3 |
* Brand Visibility v2 — scoring engine. |
| 4 |
* |
| 5 |
* Pure functions, no WordPress: every number the dashboard shows is computed |
| 6 |
* here from a flat list of completed task rows, so the maths is unit-testable |
| 7 |
* and the formulas live in exactly one place. |
| 8 |
* |
| 9 |
* Why sampling matters: an LLM answers the same question differently run to |
| 10 |
* run, so a single probe is a coin flip, not a measurement. Every metric here |
| 11 |
* is therefore a RATE over samples (mentions / samples), which is what makes |
| 12 |
* "45%" mean something. The v1 single-shot Yes/No is the noise problem this |
| 13 |
* replaces. |
| 14 |
* |
| 15 |
* @package ThinkRank\AI |
| 16 |
* @since 1.28.0 |
| 17 |
*/ |
| 18 |
|
| 19 |
declare(strict_types=1); |
| 20 |
|
| 21 |
namespace ThinkRank\AI; |
| 22 |
|
| 23 |
if (!defined('ABSPATH')) { |
| 24 |
exit; |
| 25 |
} |
| 26 |
|
| 27 |
/** |
| 28 |
* Computes brand-visibility verdicts and run aggregates. |
| 29 |
*/ |
| 30 |
final class Brand_Visibility_Scorer { |
| 31 |
|
| 32 |
/** |
| 33 |
* Visibility Index weights. Published in the UI tooltip on purpose — a |
| 34 |
* score nobody can explain is a score nobody trusts. |
| 35 |
* |
| 36 |
* Mention rate dominates because "does the AI name you at all" is the |
| 37 |
* question; share of voice is next because being named alongside three |
| 38 |
* competitors is not the same as being named alone. |
| 39 |
*/ |
| 40 |
public const WEIGHTS = [ |
| 41 |
'mention_rate' => 0.45, |
| 42 |
'share_of_voice' => 0.25, |
| 43 |
'citation_rate' => 0.15, |
| 44 |
'sentiment' => 0.15, |
| 45 |
]; |
| 46 |
|
| 47 |
/** |
| 48 |
* Query types, in the order the wizard presents them. |
| 49 |
*/ |
| 50 |
public const QUERY_TYPES = ['branded', 'category', 'problem', 'comparison']; |
| 51 |
|
| 52 |
/** |
| 53 |
* Whether a name appears in an answer as a WORD, not a substring. |
| 54 |
* |
| 55 |
* Substring matching is why naive checkers over-report: the brand "Ace" |
| 56 |
* would match "surface", and a competitor "On" would match every sentence. |
| 57 |
* Boundaries are checked against non-word characters so multi-word and |
| 58 |
* punctuated brands ("Raymond Coffee", "Yoast SEO") still match, while |
| 59 |
* `str_contains` false positives don't. |
| 60 |
* |
| 61 |
* @param string $answer Answer text to search. |
| 62 |
* @param string $name Brand or competitor name. |
| 63 |
* @return bool True when the name appears as a discrete word/phrase. |
| 64 |
*/ |
| 65 |
public static function name_appears(string $answer, string $name): bool { |
| 66 |
$name = trim($name); |
| 67 |
if ('' === $name || '' === trim($answer)) { |
| 68 |
return false; |
| 69 |
} |
| 70 |
|
| 71 |
// \b is unreliable for names ending in punctuation or non-ASCII, so |
| 72 |
// assert "not preceded/followed by a word character" explicitly. |
| 73 |
$pattern = '/(?<![\p{L}\p{N}])' . preg_quote($name, '/') . '(?![\p{L}\p{N}])/ui'; |
| 74 |
|
| 75 |
return 1 === preg_match($pattern, $answer); |
| 76 |
} |
| 77 |
|
| 78 |
/** |
| 79 |
* Whether the brand (or any of its variants) is mentioned. |
| 80 |
* |
| 81 |
* Variants exist because AI answers rarely use a brand's exact legal |
| 82 |
* string — "ThinkRank", "Think Rank" and "ThinkRank SEO" are the same |
| 83 |
* brand to a reader, and counting only the first understates visibility. |
| 84 |
* |
| 85 |
* @param string $answer Answer text. |
| 86 |
* @param string $brand Primary brand name. |
| 87 |
* @param string[] $variants Alternate spellings/aliases. |
| 88 |
* @return bool |
| 89 |
*/ |
| 90 |
public static function is_mentioned(string $answer, string $brand, array $variants = []): bool { |
| 91 |
foreach (array_merge([$brand], $variants) as $candidate) { |
| 92 |
if (self::name_appears($answer, (string) $candidate)) { |
| 93 |
return true; |
| 94 |
} |
| 95 |
} |
| 96 |
|
| 97 |
return false; |
| 98 |
} |
| 99 |
|
| 100 |
/** |
| 101 |
* Whether the answer cites the site's domain. |
| 102 |
* |
| 103 |
* Host comparison is done on the bare host (no scheme, no www) and as a |
| 104 |
* plain case-insensitive search rather than a word match, because URLs |
| 105 |
* legitimately appear glued to punctuation and paths. |
| 106 |
* |
| 107 |
* @param string $answer Answer text. |
| 108 |
* @param string $host Site host, e.g. example.com. |
| 109 |
* @return bool |
| 110 |
*/ |
| 111 |
public static function is_cited(string $answer, string $host): bool { |
| 112 |
$bare = self::bare_host($host); |
| 113 |
|
| 114 |
return '' !== $bare && false !== stripos($answer, $bare); |
| 115 |
} |
| 116 |
|
| 117 |
/** |
| 118 |
* Competitors named in an answer. |
| 119 |
* |
| 120 |
* @param string $answer Answer text. |
| 121 |
* @param array $competitors List of ['name' => string, 'url' => string]. |
| 122 |
* @return string[] Names found, in config order. |
| 123 |
*/ |
| 124 |
public static function competitors_in(string $answer, array $competitors): array { |
| 125 |
$found = []; |
| 126 |
|
| 127 |
foreach ($competitors as $competitor) { |
| 128 |
$name = (string) ($competitor['name'] ?? ''); |
| 129 |
if ('' === $name) { |
| 130 |
continue; |
| 131 |
} |
| 132 |
|
| 133 |
$host = self::bare_host((string) ($competitor['url'] ?? '')); |
| 134 |
$hit = self::name_appears($answer, $name) |
| 135 |
|| ('' !== $host && false !== stripos($answer, $host)); |
| 136 |
|
| 137 |
if ($hit) { |
| 138 |
$found[] = $name; |
| 139 |
} |
| 140 |
} |
| 141 |
|
| 142 |
return $found; |
| 143 |
} |
| 144 |
|
| 145 |
/** |
| 146 |
* Excerpt centred on the first brand hit, so the UI can show HOW the brand |
| 147 |
* came up rather than only that it did. |
| 148 |
* |
| 149 |
* @param string $answer Answer text. |
| 150 |
* @param string $brand Brand name. |
| 151 |
* @param string[] $variants Alternates. |
| 152 |
* @return string Excerpt (may be empty when nothing matched). |
| 153 |
*/ |
| 154 |
public static function excerpt(string $answer, string $brand, array $variants = []): string { |
| 155 |
foreach (array_merge([$brand], $variants) as $candidate) { |
| 156 |
$candidate = trim((string) $candidate); |
| 157 |
if ('' === $candidate) { |
| 158 |
continue; |
| 159 |
} |
| 160 |
|
| 161 |
$pos = stripos($answer, $candidate); |
| 162 |
if (false === $pos) { |
| 163 |
continue; |
| 164 |
} |
| 165 |
|
| 166 |
$start = max(0, $pos - 120); |
| 167 |
$excerpt = trim(substr($answer, $start, 300)); |
| 168 |
|
| 169 |
if ($start > 0) { |
| 170 |
$excerpt = '…' . $excerpt; |
| 171 |
} |
| 172 |
if ($start + 300 < strlen($answer)) { |
| 173 |
$excerpt .= '…'; |
| 174 |
} |
| 175 |
|
| 176 |
return $excerpt; |
| 177 |
} |
| 178 |
|
| 179 |
return ''; |
| 180 |
} |
| 181 |
|
| 182 |
/** |
| 183 |
* Aggregate completed tasks into the run's results payload. |
| 184 |
* |
| 185 |
* Only tasks that actually produced an answer are counted. A failed probe |
| 186 |
* is NOT a "not mentioned" — folding errors into the denominator would |
| 187 |
* silently depress every score, which is exactly the false-negative class |
| 188 |
* of bug that made v1 untrustworthy. |
| 189 |
* |
| 190 |
* @param array $tasks Task rows: query_text, query_type, platform, |
| 191 |
* status, mentioned, cited, sentiment, competitors. |
| 192 |
* @param array $config Run config: competitors[]. |
| 193 |
* @return array Aggregates for the dashboard. |
| 194 |
*/ |
| 195 |
public static function aggregate(array $tasks, array $config = []): array { |
| 196 |
$competitors = $config['competitors'] ?? []; |
| 197 |
|
| 198 |
$done = array_values(array_filter( |
| 199 |
$tasks, |
| 200 |
static fn(array $t): bool => 'done' === ($t['status'] ?? '') |
| 201 |
)); |
| 202 |
|
| 203 |
$samples = count($done); |
| 204 |
|
| 205 |
if (0 === $samples) { |
| 206 |
return self::empty_results($competitors); |
| 207 |
} |
| 208 |
|
| 209 |
$mentions = 0; |
| 210 |
$citations = 0; |
| 211 |
$sentiment = ['positive' => 0, 'neutral' => 0, 'negative' => 0]; |
| 212 |
|
| 213 |
// Competitor mention tallies, keyed by name and seeded so a competitor |
| 214 |
// that never appears still shows up in the leaderboard at 0. |
| 215 |
$competitor_hits = []; |
| 216 |
foreach ($competitors as $competitor) { |
| 217 |
$name = (string) ($competitor['name'] ?? ''); |
| 218 |
if ('' !== $name) { |
| 219 |
$competitor_hits[$name] = 0; |
| 220 |
} |
| 221 |
} |
| 222 |
|
| 223 |
$by_platform = []; |
| 224 |
$by_query = []; |
| 225 |
|
| 226 |
foreach ($done as $task) { |
| 227 |
$mentioned = !empty($task['mentioned']); |
| 228 |
$cited = !empty($task['cited']); |
| 229 |
|
| 230 |
$mentions += $mentioned ? 1 : 0; |
| 231 |
$citations += $cited ? 1 : 0; |
| 232 |
|
| 233 |
$mood = (string) ($task['sentiment'] ?? ''); |
| 234 |
if ($mentioned && isset($sentiment[$mood])) { |
| 235 |
$sentiment[$mood]++; |
| 236 |
} |
| 237 |
|
| 238 |
foreach (self::task_competitors($task) as $name) { |
| 239 |
if (!isset($competitor_hits[$name])) { |
| 240 |
$competitor_hits[$name] = 0; |
| 241 |
} |
| 242 |
$competitor_hits[$name]++; |
| 243 |
} |
| 244 |
|
| 245 |
$platform = (string) ($task['platform'] ?? 'unknown'); |
| 246 |
$by_platform[$platform] ??= ['platform' => $platform, 'samples' => 0, 'mentions' => 0, 'citations' => 0]; |
| 247 |
$by_platform[$platform]['samples']++; |
| 248 |
$by_platform[$platform]['mentions'] += $mentioned ? 1 : 0; |
| 249 |
$by_platform[$platform]['citations'] += $cited ? 1 : 0; |
| 250 |
|
| 251 |
$query = (string) ($task['query_text'] ?? ''); |
| 252 |
$by_query[$query] ??= [ |
| 253 |
'query' => $query, |
| 254 |
'type' => (string) ($task['query_type'] ?? 'branded'), |
| 255 |
'samples' => 0, |
| 256 |
'mentions' => 0, |
| 257 |
'citations' => 0, |
| 258 |
'platforms' => [], |
| 259 |
]; |
| 260 |
$by_query[$query]['samples']++; |
| 261 |
$by_query[$query]['mentions'] += $mentioned ? 1 : 0; |
| 262 |
$by_query[$query]['citations'] += $cited ? 1 : 0; |
| 263 |
|
| 264 |
// Track which platform mentions the brand most for this query, so |
| 265 |
// the table can answer "where am I actually winning?". |
| 266 |
$by_query[$query]['platforms'][$platform] ??= ['samples' => 0, 'mentions' => 0]; |
| 267 |
$by_query[$query]['platforms'][$platform]['samples']++; |
| 268 |
$by_query[$query]['platforms'][$platform]['mentions'] += $mentioned ? 1 : 0; |
| 269 |
} |
| 270 |
|
| 271 |
$mention_rate = self::rate($mentions, $samples); |
| 272 |
$citation_rate = self::rate($citations, $samples); |
| 273 |
|
| 274 |
// Share of voice: your mentions against the whole named field. With no |
| 275 |
// competitors configured there is no field to share, so SoV is your |
| 276 |
// mention rate — honest, and it keeps the index meaningful on free. |
| 277 |
$competitor_total = array_sum($competitor_hits); |
| 278 |
$share_of_voice = ($mentions + $competitor_total) > 0 |
| 279 |
? self::rate($mentions, $mentions + $competitor_total) |
| 280 |
: 0.0; |
| 281 |
|
| 282 |
// Sentiment share is measured over MENTIONS, not samples: an answer |
| 283 |
// that never named you has no opinion about you. |
| 284 |
$sentiment_total = array_sum($sentiment); |
| 285 |
$positive_share = $sentiment_total > 0 |
| 286 |
? self::rate($sentiment['positive'], $sentiment_total) |
| 287 |
: 0.0; |
| 288 |
|
| 289 |
// Components this run can honestly score on. |
| 290 |
$measured = ['mention_rate', 'share_of_voice', 'citation_rate']; |
| 291 |
if ($sentiment_total > 0) { |
| 292 |
$measured[] = 'sentiment'; |
| 293 |
} |
| 294 |
|
| 295 |
return [ |
| 296 |
'samples' => $samples, |
| 297 |
'mention_rate' => $mention_rate, |
| 298 |
'citation_rate' => $citation_rate, |
| 299 |
'share_of_voice' => $share_of_voice, |
| 300 |
'sentiment' => [ |
| 301 |
'positive' => $sentiment['positive'], |
| 302 |
'neutral' => $sentiment['neutral'], |
| 303 |
'negative' => $sentiment['negative'], |
| 304 |
'positive_share' => $positive_share, |
| 305 |
], |
| 306 |
// Sentiment is only part of the score when it was actually |
| 307 |
// measured. Free plans never run the sentiment probe, and an |
| 308 |
// answer that never named the brand holds no opinion of it — |
| 309 |
// scoring an unmeasured component as 0 capped those runs at 85 |
| 310 |
// no matter how visible the brand was. |
| 311 |
'measured' => $measured, |
| 312 |
'visibility_index' => self::visibility_index([ |
| 313 |
'mention_rate' => $mention_rate, |
| 314 |
'share_of_voice' => $share_of_voice, |
| 315 |
'citation_rate' => $citation_rate, |
| 316 |
'sentiment' => $positive_share, |
| 317 |
], $measured), |
| 318 |
'by_platform' => array_values(array_map( |
| 319 |
static function (array $row): array { |
| 320 |
$row['mention_rate'] = self::rate($row['mentions'], $row['samples']); |
| 321 |
$row['citation_rate'] = self::rate($row['citations'], $row['samples']); |
| 322 |
return $row; |
| 323 |
}, |
| 324 |
$by_platform |
| 325 |
)), |
| 326 |
'by_query' => array_values(array_map( |
| 327 |
static function (array $row): array { |
| 328 |
$row['mention_rate'] = self::rate($row['mentions'], $row['samples']); |
| 329 |
$row['cited'] = $row['citations'] > 0; |
| 330 |
$row['best_platform'] = self::best_platform($row['platforms']); |
| 331 |
unset($row['platforms']); |
| 332 |
return $row; |
| 333 |
}, |
| 334 |
$by_query |
| 335 |
)), |
| 336 |
'by_type' => self::by_type($by_query), |
| 337 |
'competitors' => self::leaderboard($mentions, $competitor_hits), |
| 338 |
]; |
| 339 |
} |
| 340 |
|
| 341 |
/** |
| 342 |
* Weighted 0–100 composite. |
| 343 |
* |
| 344 |
* @param array $parts Rates in 0..1 keyed like self::WEIGHTS. |
| 345 |
* @param array|null $measured Component keys this run could measure; null |
| 346 |
* means all of them. |
| 347 |
* @return int Index, 0–100. |
| 348 |
*/ |
| 349 |
public static function visibility_index(array $parts, ?array $measured = null): int { |
| 350 |
// Weights are renormalised over the components that were actually |
| 351 |
// measured, so a run that couldn't measure one of them is scored out |
| 352 |
// of what it could measure rather than penalised for the gap. |
| 353 |
$weights = null === $measured |
| 354 |
? self::WEIGHTS |
| 355 |
: array_intersect_key(self::WEIGHTS, array_flip($measured)); |
| 356 |
|
| 357 |
$total = array_sum($weights); |
| 358 |
if ($total <= 0) { |
| 359 |
return 0; |
| 360 |
} |
| 361 |
|
| 362 |
$score = 0.0; |
| 363 |
foreach ($weights as $key => $weight) { |
| 364 |
$score += ((float) ($parts[$key] ?? 0)) * ($weight / $total); |
| 365 |
} |
| 366 |
|
| 367 |
return (int) round(max(0.0, min(1.0, $score)) * 100); |
| 368 |
} |
| 369 |
|
| 370 |
/** |
| 371 |
* Share-of-voice leaderboard: you plus every configured competitor, |
| 372 |
* sorted by share so the UI can render it directly. |
| 373 |
* |
| 374 |
* @param int $own_mentions Your mention count. |
| 375 |
* @param array $competitor_hits name => mentions. |
| 376 |
* @return array<int, array{name: string, mentions: int, share: float, is_you: bool}> |
| 377 |
*/ |
| 378 |
private static function leaderboard(int $own_mentions, array $competitor_hits): array { |
| 379 |
$total = $own_mentions + array_sum($competitor_hits); |
| 380 |
|
| 381 |
$rows = [[ |
| 382 |
'name' => '', // filled by the caller/UI with "Your site" |
| 383 |
'mentions' => $own_mentions, |
| 384 |
'share' => $total > 0 ? self::rate($own_mentions, $total) : 0.0, |
| 385 |
'is_you' => true, |
| 386 |
]]; |
| 387 |
|
| 388 |
foreach ($competitor_hits as $name => $hits) { |
| 389 |
$rows[] = [ |
| 390 |
'name' => (string) $name, |
| 391 |
'mentions' => (int) $hits, |
| 392 |
'share' => $total > 0 ? self::rate((int) $hits, $total) : 0.0, |
| 393 |
'is_you' => false, |
| 394 |
]; |
| 395 |
} |
| 396 |
|
| 397 |
usort($rows, static fn(array $a, array $b): int => $b['mentions'] <=> $a['mentions']); |
| 398 |
|
| 399 |
return $rows; |
| 400 |
} |
| 401 |
|
| 402 |
/** |
| 403 |
* Mention rate per query type — feeds the radar chart. |
| 404 |
* |
| 405 |
* @param array $by_query Per-query rows. |
| 406 |
* @return array<int, array{type: string, mention_rate: float, queries: int}> |
| 407 |
*/ |
| 408 |
private static function by_type(array $by_query): array { |
| 409 |
$totals = []; |
| 410 |
|
| 411 |
foreach ($by_query as $row) { |
| 412 |
$type = (string) ($row['type'] ?? 'branded'); |
| 413 |
$totals[$type] ??= ['samples' => 0, 'mentions' => 0, 'queries' => 0]; |
| 414 |
$totals[$type]['samples'] += (int) $row['samples']; |
| 415 |
$totals[$type]['mentions'] += (int) $row['mentions']; |
| 416 |
$totals[$type]['queries']++; |
| 417 |
} |
| 418 |
|
| 419 |
$out = []; |
| 420 |
foreach (self::QUERY_TYPES as $type) { |
| 421 |
if (!isset($totals[$type])) { |
| 422 |
continue; |
| 423 |
} |
| 424 |
$out[] = [ |
| 425 |
'type' => $type, |
| 426 |
'mention_rate' => self::rate($totals[$type]['mentions'], $totals[$type]['samples']), |
| 427 |
'queries' => $totals[$type]['queries'], |
| 428 |
]; |
| 429 |
} |
| 430 |
|
| 431 |
return $out; |
| 432 |
} |
| 433 |
|
| 434 |
/** |
| 435 |
* Platform with the highest mention rate for a query (ties → most samples). |
| 436 |
* |
| 437 |
* @param array $platforms platform => ['samples' => int, 'mentions' => int]. |
| 438 |
* @return string Platform slug, or '' when nothing was mentioned anywhere. |
| 439 |
*/ |
| 440 |
private static function best_platform(array $platforms): string { |
| 441 |
$best = ''; |
| 442 |
$best_rate = 0.0; |
| 443 |
|
| 444 |
foreach ($platforms as $platform => $counts) { |
| 445 |
$rate = self::rate((int) $counts['mentions'], (int) $counts['samples']); |
| 446 |
if ($rate > $best_rate) { |
| 447 |
$best_rate = $rate; |
| 448 |
$best = (string) $platform; |
| 449 |
} |
| 450 |
} |
| 451 |
|
| 452 |
return $best; |
| 453 |
} |
| 454 |
|
| 455 |
/** |
| 456 |
* Competitor names recorded on a task row (stored as JSON). |
| 457 |
* |
| 458 |
* @param array $task Task row. |
| 459 |
* @return string[] |
| 460 |
*/ |
| 461 |
private static function task_competitors(array $task): array { |
| 462 |
$raw = $task['competitors'] ?? []; |
| 463 |
|
| 464 |
if (is_string($raw)) { |
| 465 |
$decoded = json_decode($raw, true); |
| 466 |
$raw = is_array($decoded) ? $decoded : []; |
| 467 |
} |
| 468 |
|
| 469 |
return is_array($raw) ? array_values(array_filter(array_map('strval', $raw))) : []; |
| 470 |
} |
| 471 |
|
| 472 |
/** |
| 473 |
* Host without scheme, path or www. |
| 474 |
* |
| 475 |
* @param string $url URL or bare host. |
| 476 |
* @return string |
| 477 |
*/ |
| 478 |
private static function bare_host(string $url): string { |
| 479 |
$url = trim($url); |
| 480 |
if ('' === $url) { |
| 481 |
return ''; |
| 482 |
} |
| 483 |
|
| 484 |
if (false !== strpos($url, '//')) { |
| 485 |
$parsed = wp_parse_url($url, PHP_URL_HOST); |
| 486 |
$url = is_string($parsed) ? $parsed : $url; |
| 487 |
} |
| 488 |
|
| 489 |
// Drop any path that survived (bare "example.com/shop"). |
| 490 |
$url = explode('/', $url)[0]; |
| 491 |
|
| 492 |
return strtolower(preg_replace('/^www\./i', '', $url)); |
| 493 |
} |
| 494 |
|
| 495 |
/** |
| 496 |
* Ratio rounded to 4dp, guarding division by zero. |
| 497 |
* |
| 498 |
* @param int $numerator Hits. |
| 499 |
* @param int $denominator Total. |
| 500 |
* @return float 0..1 |
| 501 |
*/ |
| 502 |
private static function rate(int $numerator, int $denominator): float { |
| 503 |
return $denominator > 0 ? round($numerator / $denominator, 4) : 0.0; |
| 504 |
} |
| 505 |
|
| 506 |
/** |
| 507 |
* Zeroed results for a run with nothing usable, so the UI renders an empty |
| 508 |
* dashboard rather than breaking on missing keys. |
| 509 |
* |
| 510 |
* @param array $competitors Configured competitors. |
| 511 |
* @return array |
| 512 |
*/ |
| 513 |
private static function empty_results(array $competitors): array { |
| 514 |
$hits = []; |
| 515 |
foreach ($competitors as $competitor) { |
| 516 |
$name = (string) ($competitor['name'] ?? ''); |
| 517 |
if ('' !== $name) { |
| 518 |
$hits[$name] = 0; |
| 519 |
} |
| 520 |
} |
| 521 |
|
| 522 |
return [ |
| 523 |
'samples' => 0, |
| 524 |
'measured' => [], |
| 525 |
'mention_rate' => 0.0, |
| 526 |
'citation_rate' => 0.0, |
| 527 |
'share_of_voice' => 0.0, |
| 528 |
'sentiment' => ['positive' => 0, 'neutral' => 0, 'negative' => 0, 'positive_share' => 0.0], |
| 529 |
'visibility_index' => 0, |
| 530 |
'by_platform' => [], |
| 531 |
'by_query' => [], |
| 532 |
'by_type' => [], |
| 533 |
'competitors' => self::leaderboard(0, $hits), |
| 534 |
]; |
| 535 |
} |
| 536 |
} |
| 537 |
|