1use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17#[non_exhaustive]
18pub struct Axis {
19 pub field: String,
21 #[serde(flatten)]
23 pub strategy: Strategy,
24 pub auto: bool,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39#[serde(tag = "strategy", rename_all = "lowercase")]
40#[non_exhaustive]
41pub enum Strategy {
42 Exact,
44 Prefix {
46 #[serde(skip_serializing_if = "Option::is_none", default)]
48 depth: Option<u8>,
49 },
50 Top {
52 n: u32,
54 },
55 Bands {
57 count: u8,
59 },
60 Quantiles {
62 count: u8,
64 },
65 Natural {
67 count: u8,
69 },
70 Similar {
72 algorithm: SimilarAlgorithm,
74 },
75}
76
77#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
79#[serde(rename_all = "lowercase")]
80#[non_exhaustive]
81pub enum SimilarAlgorithm {
82 Token,
84 Ngram,
86 Edit,
88 Lsh,
90 Simhash,
92}
93
94impl SimilarAlgorithm {
95 pub fn from_name(name: &str) -> Option<Self> {
97 match name.to_ascii_lowercase().as_str() {
98 "token" => Some(Self::Token),
99 "ngram" => Some(Self::Ngram),
100 "edit" => Some(Self::Edit),
101 "lsh" => Some(Self::Lsh),
102 "simhash" => Some(Self::Simhash),
103 _ => None,
104 }
105 }
106
107 pub fn name(self) -> &'static str {
109 match self {
110 Self::Token => "token",
111 Self::Ngram => "ngram",
112 Self::Edit => "edit",
113 Self::Lsh => "lsh",
114 Self::Simhash => "simhash",
115 #[expect(
116 unreachable_patterns,
117 reason = "guard for `#[non_exhaustive]` variants added in future slices"
118 )]
119 _ => "unknown",
120 }
121 }
122
123 pub fn matches(self, candidate: &str, representative: &str) -> bool {
127 let candidate = normalize(candidate);
128 let representative = normalize(representative);
129 if candidate.is_empty() || representative.is_empty() {
130 return candidate == representative;
131 }
132 if candidate == representative {
133 return true;
134 }
135 match self {
136 Self::Token => token_jaccard(&candidate, &representative) >= 0.5,
137 Self::Ngram => ngram_jaccard(&candidate, &representative) >= 0.45,
138 Self::Edit => normalized_edit_similarity(&candidate, &representative) >= 0.8,
139 Self::Lsh => token_jaccard(&candidate, &representative) >= 0.5,
140 Self::Simhash => simhash_distance(&candidate, &representative) <= 12,
141 #[expect(
142 unreachable_patterns,
143 reason = "guard for `#[non_exhaustive]` variants added in future slices"
144 )]
145 _ => false,
146 }
147 }
148}
149
150impl Strategy {
151 pub fn is_buffered(&self) -> bool {
157 matches!(
158 self,
159 Strategy::Quantiles { .. } | Strategy::Natural { .. } | Strategy::Similar { .. }
160 )
161 }
162
163 pub fn name(&self) -> &'static str {
180 match self {
181 Strategy::Exact => "exact",
182 Strategy::Prefix { .. } => "prefix",
183 Strategy::Top { .. } => "top",
184 Strategy::Bands { .. } => "bands",
185 Strategy::Quantiles { .. } => "quantiles",
186 Strategy::Natural { .. } => "natural",
187 Strategy::Similar { .. } => "similar",
188 #[expect(
191 unreachable_patterns,
192 reason = "guard for `#[non_exhaustive]` variants added in future slices"
193 )]
194 _ => "unknown",
195 }
196 }
197}
198
199fn normalize(value: &str) -> String {
200 value
201 .chars()
202 .map(|ch| {
203 if ch.is_alphanumeric() {
204 ch.to_ascii_lowercase()
205 } else {
206 ' '
207 }
208 })
209 .collect::<String>()
210 .split_whitespace()
211 .collect::<Vec<_>>()
212 .join(" ")
213}
214
215fn tokens(value: &str) -> std::collections::BTreeSet<&str> {
216 value.split_whitespace().collect()
217}
218
219fn token_jaccard(a: &str, b: &str) -> f64 {
220 let a = tokens(a);
221 let b = tokens(b);
222 jaccard(&a, &b)
223}
224
225fn ngrams(value: &str) -> std::collections::BTreeSet<String> {
226 let chars = value.chars().collect::<Vec<_>>();
227 if chars.len() <= 3 {
228 return std::iter::once(value.to_string()).collect();
229 }
230 chars.windows(3).map(|w| w.iter().collect()).collect()
231}
232
233fn ngram_jaccard(a: &str, b: &str) -> f64 {
234 let a = ngrams(a);
235 let b = ngrams(b);
236 jaccard(&a, &b)
237}
238
239fn jaccard<T: Ord>(a: &std::collections::BTreeSet<T>, b: &std::collections::BTreeSet<T>) -> f64 {
240 if a.is_empty() && b.is_empty() {
241 return 1.0;
242 }
243 let intersection = a.intersection(b).count() as f64;
244 let union = a.union(b).count() as f64;
245 intersection / union
246}
247
248fn normalized_edit_similarity(a: &str, b: &str) -> f64 {
249 let a_chars = a.chars().collect::<Vec<_>>();
250 let b_chars = b.chars().collect::<Vec<_>>();
251 let max_len = a_chars.len().max(b_chars.len());
252 if max_len == 0 {
253 return 1.0;
254 }
255 let distance = levenshtein(&a_chars, &b_chars);
256 1.0 - (distance as f64 / max_len as f64)
257}
258
259fn levenshtein(a: &[char], b: &[char]) -> usize {
260 let mut prev = (0..=b.len()).collect::<Vec<_>>();
261 let mut curr = vec![0usize; b.len() + 1];
262 for (i, ca) in a.iter().enumerate() {
263 curr[0] = i + 1;
264 for (j, cb) in b.iter().enumerate() {
265 let cost = usize::from(ca != cb);
266 curr[j + 1] = (curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost);
267 }
268 std::mem::swap(&mut prev, &mut curr);
269 }
270 prev[b.len()]
271}
272
273fn simhash_distance(a: &str, b: &str) -> u32 {
274 (simhash(a) ^ simhash(b)).count_ones()
275}
276
277fn simhash(value: &str) -> u64 {
278 let mut weights = [0i32; 64];
279 for token in value.split_whitespace() {
280 let hash = stable_hash(token);
281 for (bit, weight) in weights.iter_mut().enumerate() {
282 if (hash >> bit) & 1 == 1 {
283 *weight += 1;
284 } else {
285 *weight -= 1;
286 }
287 }
288 }
289 let mut out = 0u64;
290 for (bit, weight) in weights.iter().enumerate() {
291 if *weight >= 0 {
292 out |= 1u64 << bit;
293 }
294 }
295 out
296}
297
298fn stable_hash(value: &str) -> u64 {
299 let mut hash = 0xcbf29ce484222325u64;
300 for byte in value.bytes() {
301 hash ^= u64::from(byte);
302 hash = hash.wrapping_mul(0x100000001b3);
303 }
304 hash
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn exact_serializes_to_strategy_tag_only() {
313 let s = serde_json::to_value(Strategy::Exact).unwrap();
314 assert_eq!(s, serde_json::json!({ "strategy": "exact" }));
315 }
316
317 #[test]
318 fn bands_serializes_with_count() {
319 let s = serde_json::to_value(Strategy::Bands { count: 5 }).unwrap();
320 assert_eq!(s, serde_json::json!({ "strategy": "bands", "count": 5 }));
321 }
322
323 #[test]
324 fn similar_serializes_with_algorithm() {
325 let s = serde_json::to_value(Strategy::Similar {
326 algorithm: SimilarAlgorithm::Token,
327 })
328 .unwrap();
329 assert_eq!(
330 s,
331 serde_json::json!({ "strategy": "similar", "algorithm": "token" })
332 );
333 }
334
335 #[test]
336 fn quantiles_is_buffered() {
337 assert!(Strategy::Quantiles { count: 4 }.is_buffered());
338 assert!(
339 Strategy::Similar {
340 algorithm: SimilarAlgorithm::Token
341 }
342 .is_buffered()
343 );
344 assert!(!Strategy::Exact.is_buffered());
345 }
346
347 #[test]
348 fn axis_round_trip_matches_design_example() {
349 let axis = Axis {
350 field: "data.path.text".into(),
351 strategy: Strategy::Exact,
352 auto: true,
353 };
354 let v = serde_json::to_value(&axis).unwrap();
355 assert_eq!(
356 v,
357 serde_json::json!({
358 "field": "data.path.text",
359 "strategy": "exact",
360 "auto": true,
361 })
362 );
363 let back: Axis = serde_json::from_value(v).unwrap();
364 assert_eq!(back, axis);
365 }
366
367 #[test]
368 fn token_similarity_matches_reordered_terms() {
369 assert!(SimilarAlgorithm::Token.matches("fix login bug", "login bug fix"));
370 assert!(!SimilarAlgorithm::Token.matches("fix login bug", "render chart"));
371 }
372}