ngramFilter = $ngramFilter; $this->logger = $logger; } /** * @param string $rowType * @param array>|null $rows * @param string $requestedURLCleaned * @param ABJ_404_Solution_PublishedPostsProvider|null $publishedPostsProvider * @param bool $skipNgramGate4 * @return string 'applied' if prefilter was used, 'early_return' if no matches exist, * 'budget_exhausted' if the request time budget tripped, 'skipped' otherwise */ public function tryApply( string $rowType, ?array $rows, string $requestedURLCleaned, ?ABJ_404_Solution_PublishedPostsProvider $publishedPostsProvider, bool $skipNgramGate4 ): string { if ($rowType != 'pages' || $rows !== null) { return 'skipped'; } $cacheCount = $this->ngramFilter->getCacheCount(); if ($cacheCount < self::NGRAM_MIN_CACHE_ENTRIES) { $this->logger->debugMessage(sprintf( "N-gram prefilter skipped (gate 1: min entries): count=%d (need %d)", $cacheCount, self::NGRAM_MIN_CACHE_ENTRIES )); return 'skipped'; } if (!$this->ngramFilter->isCacheInitialized()) { $this->logger->debugMessage(sprintf( "N-gram prefilter skipped (gate 2: not initialized): count=%d", $cacheCount )); return 'skipped'; } $coverageRatio = $this->ngramFilter->getCacheCoverageRatio(); if ($coverageRatio < self::NGRAM_MIN_COVERAGE_RATIO) { $this->logger->debugMessage(sprintf( "N-gram prefilter skipped (gate 3: low coverage): ratio=%.2f (need %.2f)", $coverageRatio, self::NGRAM_MIN_COVERAGE_RATIO )); return 'skipped'; } if ($this->ngramFilter->hasUnindexedPermalinks()) { $this->logger->debugMessage("N-gram prefilter skipped: page-index coverage is incomplete or could not be verified"); return 'skipped'; } $similarPages = $this->ngramFilter->findSimilarPages( $requestedURLCleaned, self::NGRAM_PREFILTER_THRESHOLD, self::NGRAM_PREFILTER_MAX_CANDIDATES ); $prefilterBudget = ABJ_404_Solution_MatchingTimeBudget::current(); if ($similarPages === [] && $prefilterBudget !== null && $prefilterBudget->isExhausted()) { $this->logger->debugMessage("N-gram prefilter: time budget exhausted, skipping Levenshtein fallback"); return 'budget_exhausted'; } if (!empty($similarPages) && $publishedPostsProvider !== null) { $candidateIds = array_keys($this->similarityByScalarId($similarPages)); $publishedPostsProvider->resetBatch(); $publishedPostsProvider->restrictToIds($candidateIds); $this->logger->debugMessage(sprintf( "N-gram prefilter: Restricted to %d candidates (cache has %d entries, coverage=%.2f)", count($candidateIds), $cacheCount, $coverageRatio )); return 'applied'; } if ($skipNgramGate4) { $this->logger->debugMessage( "N-gram prefilter: zero candidates at Dice >= 0.3 - skipNgramGate4 is set, falling through to full scan" ); return 'skipped'; } $this->logger->debugMessage( "N-gram prefilter: zero candidates at Dice >= 0.3 - no similar pages exist, returning early" ); return 'early_return'; } /** * @param array $candidateIds * @param bool $ngramPrefilterApplied * @param string $requestedURLCleaned * @return array */ public function applySecondaryFilter(array $candidateIds, bool $ngramPrefilterApplied, string $requestedURLCleaned): array { $beforeNGramCount = count($candidateIds); if ($ngramPrefilterApplied || $beforeNGramCount <= self::NGRAM_SECONDARY_MIN_CANDIDATES || $this->ngramFilter->getCacheCount() < self::NGRAM_MIN_CACHE_ENTRIES || !$this->ngramFilter->isCacheInitialized() || $this->ngramFilter->getCacheCoverageRatio() < self::NGRAM_MIN_COVERAGE_RATIO || $this->ngramFilter->hasUnindexedPermalinks()) { return $this->normalizeScalarIds($candidateIds); } $similarPages = $this->ngramFilter->findSimilarPages( $requestedURLCleaned, self::NGRAM_SECONDARY_THRESHOLD, min($beforeNGramCount, self::NGRAM_SECONDARY_MAX_CANDIDATES) ); $secondaryBudget = ABJ_404_Solution_MatchingTimeBudget::current(); if ($secondaryBudget !== null && $secondaryBudget->isExhausted()) { $this->logger->debugMessage("N-gram filter (secondary): time budget exhausted, returning no candidates"); return []; } if (empty($similarPages)) { return $this->normalizeScalarIds($candidateIds); } $similarityById = $this->similarityByScalarId($similarPages); $ngramFilteredIDs = array_keys($similarityById); $candidateIds = array_intersect($this->normalizeScalarIds($candidateIds), $ngramFilteredIDs); usort($candidateIds, function($a, $b) use ($similarityById) { $keyA = (string)$a; $keyB = (string)$b; $simA = isset($similarityById[$keyA]) ? $similarityById[$keyA] : 0; $simB = isset($similarityById[$keyB]) ? $similarityById[$keyB] : 0; return $simB <=> $simA; }); $this->logger->debugMessage(sprintf( "N-gram filter (secondary): %d to %d candidates (%.1f%% reduction)", $beforeNGramCount, count($candidateIds), 100 * (1 - count($candidateIds) / max(1, $beforeNGramCount)) )); return $candidateIds; } /** * @param array $similarPages * @return array */ private function similarityByScalarId(array $similarPages): array { $normalized = array(); foreach ($similarPages as $id => $similarity) { $normalized[(string)$id] = is_numeric($similarity) ? (float)$similarity : 0.0; } return $normalized; } /** * @param array $ids * @return array */ private function normalizeScalarIds(array $ids): array { $normalized = array(); foreach ($ids as $id) { if (is_int($id) || is_string($id)) { $normalized[] = $id; } else if (is_scalar($id)) { $normalized[] = (string)$id; } } return $normalized; } }