1use crate::web::WebFeatureExtractor;
2
3pub const DEFAULT_NEAR_DUP_DISTANCE: u32 = 3;
6
7const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
8const FNV_PRIME: u64 = 0x00000100000001b3;
9const BYTE_LANE_MASK: u64 = 0x0101_0101_0101_0101;
10
11pub fn fnv1a64(bytes: &[u8]) -> u64 {
15 let mut hash = FNV_OFFSET_BASIS;
16 for byte in bytes {
17 hash ^= u64::from(*byte);
18 hash = hash.wrapping_mul(FNV_PRIME);
19 }
20 hash
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum TieBreaker {
26 Zero,
27 One,
28 Alternating,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct SimHashOptions {
34 pub tie_breaker: TieBreaker,
35}
36
37impl Default for SimHashOptions {
38 fn default() -> Self {
39 Self {
40 tie_breaker: TieBreaker::Alternating,
41 }
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct WeightedFeature {
48 pub key: String,
49 pub weight: u32,
50}
51
52impl WeightedFeature {
53 pub fn new(key: impl Into<String>, weight: u32) -> Self {
54 Self {
55 key: key.into(),
56 weight,
57 }
58 }
59
60 pub fn hash(&self) -> FeatureHash {
61 let mut bytes = Vec::with_capacity("feature:".len() + self.key.len());
62 bytes.extend_from_slice(b"feature:");
63 bytes.extend_from_slice(self.key.as_bytes());
64 FeatureHash {
65 hash: fnv1a64(&bytes),
66 weight: self.weight,
67 }
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct FeatureHash {
74 pub hash: u64,
75 pub weight: u32,
76}
77
78impl FeatureHash {
79 pub fn new(hash: u64, weight: u32) -> Self {
80 Self { hash, weight }
81 }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
86pub struct SimHash64(pub u64);
87
88impl SimHash64 {
89 pub fn from_features(features: &[WeightedFeature]) -> Self {
90 Self::from_features_with_options(features, SimHashOptions::default())
91 }
92
93 pub fn from_features_with_options(
94 features: &[WeightedFeature],
95 options: SimHashOptions,
96 ) -> Self {
97 let hashes = features.iter().map(WeightedFeature::hash);
98 Self::from_feature_hashes_with_options(hashes, options)
99 }
100
101 pub fn from_feature_hashes<I>(hashes: I) -> Self
102 where
103 I: IntoIterator<Item = FeatureHash>,
104 {
105 Self::from_feature_hashes_with_options(hashes, SimHashOptions::default())
106 }
107
108 pub fn from_feature_hashes_with_options<I>(hashes: I, options: SimHashOptions) -> Self
109 where
110 I: IntoIterator<Item = FeatureHash>,
111 {
112 let mut accum = [0i64; 64];
113 for feature in hashes {
114 if feature.weight == 0 {
115 continue;
116 }
117 let weight = i64::from(feature.weight);
118 for bit in 0..64 {
119 if ((feature.hash >> bit) & 1) == 1 {
120 accum[bit] += weight;
121 } else {
122 accum[bit] -= weight;
123 }
124 }
125 }
126 Self(bits_from_signed_accumulator(&accum, options))
127 }
128
129 pub fn from_hashes_unweighted_fast(hashes: &[u64]) -> Self {
136 Self::from_hashes_unweighted_fast_with_options(hashes, SimHashOptions::default())
137 }
138
139 pub fn from_hashes_unweighted_fast_with_options(
140 hashes: &[u64],
141 options: SimHashOptions,
142 ) -> Self {
143 let mut ones = [0u32; 64];
144 let mut lanes = [0u64; 8];
145 let mut in_lanes = 0u16;
146
147 for hash in hashes {
148 for shift in 0..8 {
149 lanes[shift] = lanes[shift].wrapping_add((hash >> shift) & BYTE_LANE_MASK);
150 }
151 in_lanes += 1;
152 if in_lanes == 255 {
153 flush_lanes(&mut ones, &mut lanes);
154 in_lanes = 0;
155 }
156 }
157
158 if in_lanes > 0 {
159 flush_lanes(&mut ones, &mut lanes);
160 }
161
162 let total = hashes.len() as i64;
163 let mut accum = [0i64; 64];
164 for bit in 0..64 {
165 accum[bit] = i64::from(ones[bit]) * 2 - total;
166 }
167 Self(bits_from_signed_accumulator(&accum, options))
168 }
169
170 pub fn from_text(text: &str, extractor: &WebFeatureExtractor) -> Self {
171 let features = extractor.extract_from_text(text);
172 Self::from_features(&features)
173 }
174
175 pub fn from_html(html: &str, extractor: &WebFeatureExtractor) -> Self {
176 let features = extractor.extract_from_html(html);
177 Self::from_features(&features)
178 }
179
180 pub fn hamming_distance(self, other: Self) -> u32 {
181 (self.0 ^ other.0).count_ones()
182 }
183
184 pub fn similarity(self, other: Self) -> f64 {
185 1.0 - f64::from(self.hamming_distance(other)) / 64.0
186 }
187
188 pub fn is_near_duplicate(self, other: Self, max_distance: u32) -> bool {
189 self.hamming_distance(other) <= max_distance
190 }
191}
192
193fn flush_lanes(ones: &mut [u32; 64], lanes: &mut [u64; 8]) {
194 for shift in 0..8 {
195 let lane = lanes[shift];
196 for byte in 0..8 {
197 let bit = shift + byte * 8;
198 ones[bit] += ((lane >> (byte * 8)) & 0xff) as u32;
199 }
200 lanes[shift] = 0;
201 }
202}
203
204fn bits_from_signed_accumulator(accum: &[i64; 64], options: SimHashOptions) -> u64 {
205 let mut value = 0u64;
206 for (bit, count) in accum.iter().enumerate() {
207 let set = match count.cmp(&0) {
208 std::cmp::Ordering::Greater => true,
209 std::cmp::Ordering::Less => false,
210 std::cmp::Ordering::Equal => match options.tie_breaker {
211 TieBreaker::Zero => false,
212 TieBreaker::One => true,
213 TieBreaker::Alternating => bit % 2 == 1,
214 },
215 };
216 if set {
217 value |= 1u64 << bit;
218 }
219 }
220 value
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 #[test]
228 fn fnv1a_is_deterministic() {
229 assert_eq!(fnv1a64(b"test string"), 10983430520173899754);
230 assert_eq!(fnv1a64(b"test string"), fnv1a64(b"test string"));
231 assert_ne!(fnv1a64(b"test string"), fnv1a64(b"test thing"));
232 }
233
234 #[test]
235 fn weighted_features_change_the_result() {
236 let light = vec![
237 WeightedFeature::new("term:my", 1),
238 WeightedFeature::new("term:car", 1),
239 WeightedFeature::new("term:black", 1),
240 ];
241 let heavy = vec![
242 WeightedFeature::new("term:my", 1),
243 WeightedFeature::new("term:car", 1),
244 WeightedFeature::new("term:black", 8),
245 ];
246
247 assert_ne!(
248 SimHash64::from_features(&light),
249 SimHash64::from_features(&heavy)
250 );
251 }
252
253 #[test]
254 fn explicit_feature_weights_match_repetition() {
255 let repeated_hash = fnv1a64(b"feature:repeated");
256 let weighted = SimHash64::from_feature_hashes([FeatureHash::new(repeated_hash, 3)]);
257 let repeated = SimHash64::from_feature_hashes([
258 FeatureHash::new(repeated_hash, 1),
259 FeatureHash::new(repeated_hash, 1),
260 FeatureHash::new(repeated_hash, 1),
261 ]);
262
263 assert_eq!(weighted, repeated);
264 }
265
266 #[test]
267 fn hamming_distance_and_similarity_work() {
268 let a = SimHash64(0);
269 let b = SimHash64(u64::MAX);
270 let c = SimHash64(0b1011);
271
272 assert_eq!(a.hamming_distance(a), 0);
273 assert_eq!(a.hamming_distance(b), 64);
274 assert_eq!(a.hamming_distance(c), 3);
275 assert_eq!(a.similarity(a), 1.0);
276 assert_eq!(a.similarity(b), 0.0);
277 }
278
279 #[test]
280 fn fast_unweighted_path_matches_standard_path() {
281 let hashes: Vec<u64> = (0..600)
282 .map(|n| fnv1a64(format!("feature-{n}").as_bytes()))
283 .collect();
284 let fast = SimHash64::from_hashes_unweighted_fast(&hashes);
285 let standard =
286 SimHash64::from_feature_hashes(hashes.iter().copied().map(|h| FeatureHash::new(h, 1)));
287
288 assert_eq!(fast, standard);
289 }
290
291 #[test]
292 fn tie_breakers_are_deterministic() {
293 let empty_zero = SimHash64::from_feature_hashes_with_options(
294 [],
295 SimHashOptions {
296 tie_breaker: TieBreaker::Zero,
297 },
298 );
299 let empty_one = SimHash64::from_feature_hashes_with_options(
300 [],
301 SimHashOptions {
302 tie_breaker: TieBreaker::One,
303 },
304 );
305 let empty_alt = SimHash64::from_feature_hashes_with_options(
306 [],
307 SimHashOptions {
308 tie_breaker: TieBreaker::Alternating,
309 },
310 );
311
312 assert_eq!(empty_zero.0, 0);
313 assert_eq!(empty_one.0, u64::MAX);
314 assert_eq!(empty_alt.0, 0xaaaa_aaaa_aaaa_aaaa);
315 }
316}