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 / SpellNGramPrefilter.php

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

186 lines 5.6 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 * Applies N-gram cache health gates before expensive spell-candidate scans.
9 */
10 class ABJ_404_Solution_SpellNGramPrefilter {
11
12 const NGRAM_PREFILTER_THRESHOLD = 0.3;
13 const NGRAM_PREFILTER_MAX_CANDIDATES = 500;
14 const NGRAM_MIN_CACHE_ENTRIES = 50;
15 const NGRAM_SECONDARY_THRESHOLD = 0.4;
16 const NGRAM_SECONDARY_MAX_CANDIDATES = 100;
17 const NGRAM_MIN_COVERAGE_RATIO = 0.8;
18 const NGRAM_SECONDARY_MIN_CANDIDATES = 50;
19
20 /** @var ABJ_404_Solution_NGramFilter */
21 private $ngramFilter;
22
23 /** @var ABJ_404_Solution_Logging */
24 private $logger;
25
26 /**
27 * @param ABJ_404_Solution_NGramFilter $ngramFilter
28 * @param ABJ_404_Solution_Logging $logger
29 */
30 public function __construct($ngramFilter, $logger) {
31 $this->ngramFilter = $ngramFilter;
32 $this->logger = $logger;
33 }
34
35 /**
36 * @param string $rowType
37 * @param array<int, array<string, mixed>>|null $rows
38 * @param string $requestedURLCleaned
39 * @param ABJ_404_Solution_PublishedPostsProvider|null $publishedPostsProvider
40 * @param bool $skipNgramGate4
41 * @return string 'applied' if prefilter was used, 'early_return' if no matches exist, 'skipped' otherwise
42 */
43 public function tryApply(
44 string $rowType,
45 ?array $rows,
46 string $requestedURLCleaned,
47 ?ABJ_404_Solution_PublishedPostsProvider $publishedPostsProvider,
48 bool $skipNgramGate4
49 ): string {
50 if ($rowType != 'pages' || $rows !== null) {
51 return 'skipped';
52 }
53
54 $cacheCount = $this->ngramFilter->getCacheCount();
55
56 if ($cacheCount < self::NGRAM_MIN_CACHE_ENTRIES) {
57 $this->logger->debugMessage(sprintf(
58 "N-gram prefilter skipped (gate 1: min entries): count=%d (need %d)",
59 $cacheCount,
60 self::NGRAM_MIN_CACHE_ENTRIES
61 ));
62 return 'skipped';
63 }
64 if (!$this->ngramFilter->isCacheInitialized()) {
65 $this->logger->debugMessage(sprintf(
66 "N-gram prefilter skipped (gate 2: not initialized): count=%d",
67 $cacheCount
68 ));
69 return 'skipped';
70 }
71 $coverageRatio = $this->ngramFilter->getCacheCoverageRatio();
72 if ($coverageRatio < self::NGRAM_MIN_COVERAGE_RATIO) {
73 $this->logger->debugMessage(sprintf(
74 "N-gram prefilter skipped (gate 3: low coverage): ratio=%.2f (need %.2f)",
75 $coverageRatio,
76 self::NGRAM_MIN_COVERAGE_RATIO
77 ));
78 return 'skipped';
79 }
80
81 $similarPages = $this->ngramFilter->findSimilarPages(
82 $requestedURLCleaned,
83 self::NGRAM_PREFILTER_THRESHOLD,
84 self::NGRAM_PREFILTER_MAX_CANDIDATES
85 );
86
87 if (!empty($similarPages) && $publishedPostsProvider !== null) {
88 $candidateIds = array_keys($this->similarityByScalarId($similarPages));
89 $publishedPostsProvider->resetBatch();
90 $publishedPostsProvider->restrictToIds($candidateIds);
91 $this->logger->debugMessage(sprintf(
92 "N-gram prefilter: Restricted to %d candidates (cache has %d entries, coverage=%.2f)",
93 count($candidateIds),
94 $cacheCount,
95 $coverageRatio
96 ));
97 return 'applied';
98 }
99
100 if ($skipNgramGate4) {
101 $this->logger->debugMessage(
102 "N-gram prefilter: zero candidates at Dice >= 0.3 - skipNgramGate4 is set, falling through to full scan"
103 );
104 return 'skipped';
105 }
106
107 $this->logger->debugMessage(
108 "N-gram prefilter: zero candidates at Dice >= 0.3 - no similar pages exist, returning early"
109 );
110 return 'early_return';
111 }
112
113 /**
114 * @param array<int, mixed> $candidateIds
115 * @param bool $ngramPrefilterApplied
116 * @param string $requestedURLCleaned
117 * @return array<int, int|string>
118 */
119 public function applySecondaryFilter(array $candidateIds, bool $ngramPrefilterApplied, string $requestedURLCleaned): array {
120 $beforeNGramCount = count($candidateIds);
121 if ($ngramPrefilterApplied
122 || $beforeNGramCount <= self::NGRAM_SECONDARY_MIN_CANDIDATES
123 || $this->ngramFilter->getCacheCount() < self::NGRAM_MIN_CACHE_ENTRIES
124 || !$this->ngramFilter->isCacheInitialized()
125 || $this->ngramFilter->getCacheCoverageRatio() < self::NGRAM_MIN_COVERAGE_RATIO) {
126 return $this->normalizeScalarIds($candidateIds);
127 }
128
129 $similarPages = $this->ngramFilter->findSimilarPages(
130 $requestedURLCleaned,
131 self::NGRAM_SECONDARY_THRESHOLD,
132 min($beforeNGramCount, self::NGRAM_SECONDARY_MAX_CANDIDATES)
133 );
134 if (empty($similarPages)) {
135 return $this->normalizeScalarIds($candidateIds);
136 }
137
138 $similarityById = $this->similarityByScalarId($similarPages);
139 $ngramFilteredIDs = array_keys($similarityById);
140 $candidateIds = array_intersect($this->normalizeScalarIds($candidateIds), $ngramFilteredIDs);
141 usort($candidateIds, function($a, $b) use ($similarityById) {
142 $keyA = (string)$a;
143 $keyB = (string)$b;
144 $simA = isset($similarityById[$keyA]) ? $similarityById[$keyA] : 0;
145 $simB = isset($similarityById[$keyB]) ? $similarityById[$keyB] : 0;
146 return $simB <=> $simA;
147 });
148 $this->logger->debugMessage(sprintf(
149 "N-gram filter (secondary): %d to %d candidates (%.1f%% reduction)",
150 $beforeNGramCount,
151 count($candidateIds),
152 100 * (1 - count($candidateIds) / max(1, $beforeNGramCount))
153 ));
154
155 return $candidateIds;
156 }
157
158 /**
159 * @param array<int|string, mixed> $similarPages
160 * @return array<string, float>
161 */
162 private function similarityByScalarId(array $similarPages): array {
163 $normalized = array();
164 foreach ($similarPages as $id => $similarity) {
165 $normalized[(string)$id] = is_numeric($similarity) ? (float)$similarity : 0.0;
166 }
167 return $normalized;
168 }
169
170 /**
171 * @param array<int, mixed> $ids
172 * @return array<int, int|string>
173 */
174 private function normalizeScalarIds(array $ids): array {
175 $normalized = array();
176 foreach ($ids as $id) {
177 if (is_int($id) || is_string($id)) {
178 $normalized[] = $id;
179 } else if (is_scalar($id)) {
180 $normalized[] = (string)$id;
181 }
182 }
183 return $normalized;
184 }
185 }
186