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 / ngram / NGramSimilarity.php

NGramSimilarity.php in 404 Solution trunk, at includes/ngram/NGramSimilarity.php

106 lines 3.7 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 * N-gram set similarity primitives.
9 *
10 * Owns the Dice coefficient (set-overlap similarity) and the proximity-merge
11 * routine that interleaves two pre-sorted result arrays by distance to a
12 * target ngram count. Pure computation; no I/O.
13 *
14 * Used by NGramFilter::findSimilarPages() during candidate selection and by
15 * NGramCacheRepository::getCachedNGramsFiltered() to merge below/above-target
16 * query halves. Algorithm tests exercise these directly.
17 */
18 class ABJ_404_Solution_NGramSimilarity {
19
20 /**
21 * Compute Dice coefficient similarity between two N-gram sets.
22 *
23 * Dice coefficient: 2 * |intersection| / (|set1| + |set2|)
24 * Range: 0.0 (no overlap) to 1.0 (identical).
25 *
26 * Threshold correlation:
27 * - 0.4 = ~30% edit distance (recommended)
28 * - 0.5 = ~20% edit distance
29 * - 0.6 = ~10% edit distance
30 *
31 * @param array{bi?: array<int, string>, tri?: array<int, string>} $ngrams1
32 * @param array{bi?: array<int, string>, tri?: array<int, string>} $ngrams2
33 * @return float Similarity score 0.0 to 1.0
34 */
35 public function diceCoefficient($ngrams1, $ngrams2) {
36 $set1 = array_merge(
37 isset($ngrams1['bi']) ? $ngrams1['bi'] : [],
38 isset($ngrams1['tri']) ? $ngrams1['tri'] : []
39 );
40 $set2 = array_merge(
41 isset($ngrams2['bi']) ? $ngrams2['bi'] : [],
42 isset($ngrams2['tri']) ? $ngrams2['tri'] : []
43 );
44
45 if (empty($set1) || empty($set2)) {
46 return 0.0;
47 }
48
49 $set1 = array_flip($set1);
50 $set2 = array_flip($set2);
51
52 $intersection = count(array_intersect_key($set1, $set2));
53
54 // Defensive: empty() check above should guarantee denominator > 0.
55 $denominator = count($set1) + count($set2);
56 return ($denominator > 0) ? (2.0 * $intersection) / $denominator : 0.0;
57 }
58
59 /**
60 * Merge two arrays sorted by proximity to target, interleaving results.
61 *
62 * Both input arrays must be pre-sorted by proximity to the target:
63 * - $below: ngram_count <= target, ordered DESC by ngram_count (closest first)
64 * - $above: ngram_count > target, ordered ASC by ngram_count (closest first)
65 *
66 * @param array<int, mixed> $below
67 * @param array<int, mixed> $above
68 * @param int $targetNgramCount
69 * @param int $limit
70 * @return array<int, mixed>
71 */
72 public function mergeByProximity($below, $above, $targetNgramCount, $limit) {
73 $result = [];
74 $i = 0;
75 $j = 0;
76 $belowCount = count($below);
77 $aboveCount = count($above);
78
79 while (count($result) < $limit && ($i < $belowCount || $j < $aboveCount)) {
80 $belowEntry = $below[$i] ?? null;
81 $aboveEntry = $above[$j] ?? null;
82 $belowNgramRaw = (is_array($belowEntry) && isset($belowEntry['ngram_count'])) ? $belowEntry['ngram_count'] : 0;
83 $belowNgramCount = is_scalar($belowNgramRaw) ? (int)$belowNgramRaw : 0;
84 $aboveNgramRaw = (is_array($aboveEntry) && isset($aboveEntry['ngram_count'])) ? $aboveEntry['ngram_count'] : 0;
85 $aboveNgramCount = is_scalar($aboveNgramRaw) ? (int)$aboveNgramRaw : 0;
86 $distBelow = ($i < $belowCount)
87 ? abs($belowNgramCount - $targetNgramCount)
88 : PHP_INT_MAX;
89 $distAbove = ($j < $aboveCount)
90 ? abs($aboveNgramCount - $targetNgramCount)
91 : PHP_INT_MAX;
92
93 // Prefer below on tie (includes exact matches)
94 if ($distBelow <= $distAbove) {
95 $result[] = $below[$i];
96 $i++;
97 } else {
98 $result[] = $above[$j];
99 $j++;
100 }
101 }
102
103 return $result;
104 }
105 }
106