PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / spelling / SpellLevenshteinEngine.php

SpellLevenshteinEngine.php in 404 Solution trunk, at includes/spelling/SpellLevenshteinEngine.php

354 lines 11.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Levenshtein distance engine and candidate pre-filtering for the
9 * spell-checking subsystem.
10 *
11 * Extracted from SpellCheckerTrait_LevenshteinEngine as a standalone class
12 * with explicit dependency injection.
13 */
14 class ABJ_404_Solution_SpellLevenshteinEngine {
15
16 const NGRAM_PREFILTER_THRESHOLD = 0.3;
17
18 const NGRAM_PREFILTER_MAX_CANDIDATES = 500;
19
20 const NGRAM_MIN_CACHE_ENTRIES = 50;
21
22 const NGRAM_SECONDARY_THRESHOLD = 0.4;
23
24 const NGRAM_SECONDARY_MAX_CANDIDATES = 100;
25
26 const NGRAM_MIN_COVERAGE_RATIO = 0.8;
27
28 const NGRAM_SECONDARY_MIN_CANDIDATES = 50;
29
30 /** @var ABJ_404_Solution_Functions */
31 private $f;
32
33 /** @var ABJ_404_Solution_Logging */
34 private $logger;
35
36 /** @var ABJ_404_Solution_ContentRepository */
37 private $contentRepository;
38
39 /** @var ABJ_404_Solution_SpellURLMatcher */
40 private $urlMatcher;
41
42 /** @var ABJ_404_Solution_SpellNGramPrefilter */
43 private $ngramPrefilter;
44
45 /** @var ABJ_404_Solution_SpellCandidatePermalinkLookup */
46 private $permalinkLookup;
47
48 /** @var ABJ_404_Solution_SpellLevenshteinEngineDependencies the bundle reused to build a per-call distance ranker */
49 private $deps;
50
51 private bool $enablePerformanceCounters = false;
52
53 private bool $skipNgramGate4 = false;
54
55 private int $levenshteinCallCount = 0;
56
57 private int $totalPagesConsidered = 0;
58
59 /** @var ABJ_404_Solution_PublishedPostsProvider|null */
60 private ?ABJ_404_Solution_PublishedPostsProvider $publishedPostsProvider = null;
61
62 /**
63 * @param ABJ_404_Solution_SpellLevenshteinEngineDependencies $deps
64 */
65 public function __construct(ABJ_404_Solution_SpellLevenshteinEngineDependencies $deps) {
66 $this->deps = $deps;
67 $this->f = $deps->functions;
68 $this->logger = $deps->logger;
69 $this->contentRepository = $deps->contentRepository;
70 $this->urlMatcher = $deps->urlMatcher;
71 $this->ngramPrefilter = new ABJ_404_Solution_SpellNGramPrefilter($deps->ngramFilter, $deps->logger);
72 $this->permalinkLookup = new ABJ_404_Solution_SpellCandidatePermalinkLookup($deps->contentRepository, $deps->urlMatcher);
73 }
74
75 /** @param ABJ_404_Solution_PublishedPostsProvider|null $provider */
76 public function setPublishedPostsProvider(?ABJ_404_Solution_PublishedPostsProvider $provider): void {
77 $this->publishedPostsProvider = $provider;
78 }
79
80 public function enablePerformanceCounters(bool $enable = true): void {
81 $this->enablePerformanceCounters = $enable;
82 if ($enable) {
83 $this->resetPerformanceCounters();
84 }
85 }
86
87 public function setSkipNgramGate4(bool $skip = true): void {
88 $this->skipNgramGate4 = $skip;
89 }
90
91 public function resetPerformanceCounters(): void {
92 $this->levenshteinCallCount = 0;
93 $this->totalPagesConsidered = 0;
94 }
95
96 /**
97 * @return array{levenshtein_calls: int, pages_considered: int, efficiency_percent: float}
98 */
99 public function getPerformanceCounters(): array {
100 $efficiency = 0;
101 if ($this->totalPagesConsidered > 0) {
102 $efficiency = ($this->levenshteinCallCount / $this->totalPagesConsidered) * 100;
103 }
104
105 return [
106 'levenshtein_calls' => $this->levenshteinCallCount,
107 'pages_considered' => $this->totalPagesConsidered,
108 'efficiency_percent' => round($efficiency, 2)
109 ];
110 }
111
112 /**
113 * @param string $requestedURLCleaned
114 * @param string $fullURLspaces
115 * @param string $rowType
116 * @param array<int, array<string, mixed>>|null $rows
117 * @return array<int|string, mixed>
118 */
119 function getLikelyMatchIDs(string $requestedURLCleaned, string $fullURLspaces, string $rowType, ?array $rows = null) {
120
121 $options = abj_service('options_repository')->getOptions();
122 $suggestMaxLikely = isset($options['suggest_max']) && is_scalar($options['suggest_max']) ? $options['suggest_max'] : 5;
123 $onlyNeedThisManyPages = min(5 * absint($suggestMaxLikely), 100);
124
125 $ngramPrefilterResult = $this->ngramPrefilter->tryApply(
126 $rowType,
127 $rows,
128 $requestedURLCleaned,
129 $this->publishedPostsProvider,
130 $this->skipNgramGate4
131 );
132 if ($ngramPrefilterResult === 'early_return') {
133 return array();
134 }
135 $ngramPrefilterApplied = ($ngramPrefilterResult === 'applied');
136
137 $ranker = new ABJ_404_Solution_SpellCandidateDistanceRanker($this->deps, $this->ngramPrefilter);
138
139 $requestedURLCleanedLength = $this->f->strlen($requestedURLCleaned);
140 $fullURLspacesLength = $this->f->strlen($fullURLspaces);
141
142 $userRequestedURLWords = explode(" ", (empty($fullURLspaces) ? $requestedURLCleaned : $fullURLspaces));
143 $observedPermalinksById = array();
144 $wasntReadyCount = 0;
145
146 if ($this->publishedPostsProvider === null) {
147 return array();
148 }
149 $postsProvider = $this->publishedPostsProvider;
150 if (!$ngramPrefilterApplied) {
151 $postsProvider->resetBatch();
152 }
153 if ($rows != null) {
154 $postsProvider->useThisData($rows);
155 }
156 $currentBatch = $postsProvider->getNextBatch($requestedURLCleanedLength);
157
158 $row = array_pop($currentBatch);
159 while ($row != null) {
160 $row = (array)$row;
161
162 if ($this->enablePerformanceCounters) {
163 $this->totalPagesConsidered++;
164 }
165
166 $id = $this->extractRowCandidateId($row, $rowType);
167 if ($id === null) {
168 $row = array_pop($currentBatch);
169 continue;
170 }
171 $idInt = is_scalar($id) ? (int)$id : 0;
172
173 $the_permalink = null;
174 $urlPath = null;
175 $this->resolveCandidatePermalinkParts(
176 $row, $idInt, $rowType, $wasntReadyCount, $the_permalink, $urlPath
177 );
178
179 abj_service('request_context')->debug_info = 'Likely match IDs processing permalink: ' .
180 $the_permalink . ', $wasntReadyCount: ' . $wasntReadyCount;
181
182 if ($urlPath === null) {
183 // Skip this candidate (no parseable path) AND advance to the next
184 // row, exactly like the $id === null skip above. A bare `continue`
185 // here would re-evaluate the SAME row forever -- a per-request
186 // infinite loop that hangs the 404 response until PHP's
187 // max_execution_time kills it (the worst "not fast" outcome on a
188 // large/diverse site where some candidate has an unparseable URL).
189 $row = array_pop($currentBatch);
190 continue;
191 }
192 if (is_string($the_permalink)) {
193 $observedPermalinksById[$idInt] = $the_permalink;
194 }
195
196 $ranker->score(
197 $id, $urlPath, $requestedURLCleanedLength,
198 $fullURLspaces, $fullURLspacesLength, $userRequestedURLWords
199 );
200
201 $row = array_pop($currentBatch);
202 if ($row == null) {
203 $maxAcceptableDistance = $ranker->getMaxAcceptableDistance($onlyNeedThisManyPages);
204
205 $currentBatch = $postsProvider->getNextBatch(
206 $requestedURLCleanedLength, 1000, $maxAcceptableDistance);
207 $row = array_pop($currentBatch);
208 }
209 }
210 abj_service('request_context')->debug_info = '';
211
212 if ($wasntReadyCount > 0) {
213 $this->logger->infoMessage("The permalink cache wasn't ready for " . $wasntReadyCount . " IDs.");
214 }
215
216 $candidateIds = $ranker->prioritize(
217 $onlyNeedThisManyPages, $ngramPrefilterApplied, $requestedURLCleaned
218 );
219
220 return $this->permalinkLookup->lookup(
221 array_values(array_unique($candidateIds)), $rowType, $observedPermalinksById
222 );
223 }
224
225 /**
226 * Pull the candidate id out of one published-content row, dispatching on
227 * the row type. Returns null when the row carries no usable id (the caller
228 * skips it); throws for an unrecognized row type.
229 *
230 * @param array<mixed, mixed> $row
231 * @param string $rowType
232 * @return mixed the raw candidate id as stored in the row, or null when absent
233 */
234 private function extractRowCandidateId(array $row, string $rowType) {
235 if ($rowType == 'pages') {
236 return $row['id'];
237
238 } else if ($rowType == 'tags') {
239 return array_key_exists('term_id', $row) ? $row['term_id'] : null;
240
241 } else if ($rowType == 'categories') {
242 return array_key_exists('term_id', $row) ? $row['term_id'] : null;
243
244 } else if ($rowType == 'image') {
245 return $row['id'];
246 }
247
248 throw new \Exception("Unknown row type ... " . esc_html($rowType)); // allow-raw-error: assertion, should never reach user
249 }
250
251 /**
252 * Resolve a candidate's permalink and the path portion of its URL. Prefers
253 * the url carried on the row; falls back to the permalink cache (incrementing
254 * $wasntReadyCount) when the row has no usable url or the row url failed to
255 * parse. The permalink and url path are returned through the by-reference
256 * out-parameters to keep this hot-path step allocation-free. $urlPath is null
257 * when no parseable path could be resolved (the caller then skips the row).
258 *
259 * @param array<mixed, mixed> $row
260 * @param int $idInt
261 * @param string $rowType
262 * @param int $wasntReadyCount incremented by reference on a cache fallback
263 * @param string|null $the_permalink
264 * @param string|null $urlPath
265 * @param-out string|null $the_permalink
266 * @param-out string|null $urlPath
267 */
268 private function resolveCandidatePermalinkParts(
269 array $row, int $idInt, string $rowType, int &$wasntReadyCount, &$the_permalink, &$urlPath
270 ): void {
271 $the_permalink = null;
272 $urlPath = null;
273 $urlParts = null;
274 if (array_key_exists('url', $row)) {
275 $the_permalink = isset($row['url']) && is_string($row['url']) ? $row['url'] : '';
276 $the_permalink = abj_service('sanitizer')->normalizeUrlString($the_permalink);
277 $urlParts = parse_url($the_permalink);
278
279 if (is_bool($urlParts)) {
280 $this->contentRepository->removeFromPermalinkCache($idInt);
281 }
282 }
283 if (!array_key_exists('url', $row) || (isset($urlParts) && is_bool($urlParts))) {
284 $wasntReadyCount++;
285 $the_permalink = $this->urlMatcher->getPermalink($idInt, $rowType);
286 $the_permalink = abj_service('sanitizer')->normalizeUrlString($the_permalink);
287 $urlParts = parse_url($the_permalink);
288 }
289
290 if (is_array($urlParts) && array_key_exists('path', $urlParts)) {
291 $urlPath = $urlParts['path'];
292 }
293 }
294
295 /**
296 * @param string $str1
297 * @param string $str2
298 * @return int
299 * @throws Exception
300 */
301 function customLevenshtein($str1, $str2) {
302 if ($this->enablePerformanceCounters) {
303 $this->levenshteinCallCount++;
304 }
305 abj_service('request_context')->debug_info = 'customLevenshtein. str1: ' . esc_html($str1) . ', str2: ' . esc_html($str2);
306
307 $RowLen = $this->f->strlen($str1);
308 $ColLen = $this->f->strlen($str2);
309 $cost = 0;
310
311 if (max($RowLen, $ColLen) > ABJ404_MAX_URL_LENGTH) {
312 throw new \Exception("Maximum string length in customLevenshtein is " . // allow-raw-error: assertion, should never reach user
313 ABJ404_MAX_URL_LENGTH . ". Yours is " . max($RowLen, $ColLen) . ".");
314 }
315
316 if (strlen($str1) <= 255 && strlen($str2) <= 255) {
317 return levenshtein($str1, $str2);
318 }
319
320 if ($RowLen == 0) {
321 return $ColLen;
322 } else if ($ColLen == 0) {
323 return $RowLen;
324 }
325
326 $chars1 = mb_str_split($str1, 1, 'UTF-8');
327 $chars2 = mb_str_split($str2, 1, 'UTF-8');
328
329 $v0 = array_fill(0, $RowLen + 1, 0);
330 $v1 = array_fill(0, $RowLen + 1, 0);
331
332 for ($RowIdx = 1; $RowIdx <= $RowLen; $RowIdx++) {
333 $v0[$RowIdx] = $RowIdx;
334 }
335
336 for ($ColIdx = 1; $ColIdx <= $ColLen; $ColIdx++) {
337 $v1[0] = $ColIdx;
338
339 for ($RowIdx = 1; $RowIdx <= $RowLen; $RowIdx++) {
340 $cost = ($chars1[$RowIdx - 1] === $chars2[$ColIdx - 1]) ? 0 : 1;
341 $v1[$RowIdx] = min($v0[$RowIdx] + 1, $v1[$RowIdx - 1] + 1, $v0[$RowIdx - 1] + $cost);
342 }
343
344 $vTmp = $v0;
345 $v0 = $v1;
346 $v1 = $vTmp;
347 }
348
349 abj_service('request_context')->debug_info = 'Cleared after customLevenshtein.';
350 return $v0[$RowLen];
351 }
352
353 }
354