1use std::collections::HashMap;
4
5#[inline]
9pub fn is_cjk_char(c: char) -> bool {
10 matches!(c,
11 '\u{3040}'..='\u{309F}' | '\u{30A0}'..='\u{30FF}' | '\u{3400}'..='\u{4DBF}' | '\u{4E00}'..='\u{9FFF}' | '\u{F900}'..='\u{FAFF}' | '\u{AC00}'..='\u{D7AF}' | '\u{20000}'..='\u{2A6DF}' )
19}
20
21pub fn contains_cjk(text: &str) -> bool {
23 let (cjk_count, char_count) = cjk_counts(text);
24 if char_count == 0 {
25 return false;
26 }
27 (cjk_count as f64 / char_count as f64) > 0.15
28}
29
30pub fn contains_cjk_f32(text: &str) -> bool {
33 let (cjk_count, char_count) = cjk_counts(text);
34 if char_count == 0 {
35 return false;
36 }
37 (cjk_count as f32 / char_count as f32) > 0.15
38}
39
40fn cjk_counts(text: &str) -> (usize, usize) {
41 text.chars().fold((0, 0), |(cjk, total), ch| {
42 (cjk + usize::from(is_cjk_char(ch)), total + 1)
43 })
44}
45
46#[derive(Debug, Clone, PartialEq)]
50pub struct ScriptProfile {
51 pub cjk_fraction: f64,
53 pub latin_fraction: f64,
55 pub char_count: usize,
57}
58
59impl ScriptProfile {
60 pub fn analyze(text: &str) -> Self {
62 let chars: Vec<char> = text.chars().collect();
63 let n = chars.len();
64 if n == 0 {
65 return Self {
66 cjk_fraction: 0.0,
67 latin_fraction: 0.0,
68 char_count: 0,
69 };
70 }
71 let cjk = chars.iter().filter(|&&c| is_cjk_char(c)).count();
72 let latin = chars.iter().filter(|&&c| c.is_ascii_alphabetic()).count();
73 Self {
74 cjk_fraction: cjk as f64 / n as f64,
75 latin_fraction: latin as f64 / n as f64,
76 char_count: n,
77 }
78 }
79
80 pub fn is_cjk_dominant(&self) -> bool {
82 self.cjk_fraction > 0.15
83 }
84}
85
86pub fn is_meaningful_query(query: &str) -> bool {
91 let trimmed = query.trim();
92 if trimmed.is_empty() {
93 return false;
94 }
95
96 let non_ws: Vec<char> = trimmed.chars().filter(|c| !c.is_whitespace()).collect();
97 if non_ws.is_empty() {
98 return false;
99 }
100
101 if !non_ws.iter().any(|c| c.is_alphanumeric()) {
103 return false;
104 }
105
106 if non_ws.len() == 1 && non_ws[0].is_ascii_alphabetic() {
108 return false;
109 }
110
111 let total = non_ws.len();
114 if total > 1 {
115 let mut counts: HashMap<char, usize> = HashMap::new();
116 for c in &non_ws {
117 *counts.entry(*c).or_insert(0) += 1;
118 }
119 if let Some(&max_count) = counts.values().max() {
120 if max_count as f64 / total as f64 > 0.80 {
121 return false;
122 }
123 }
124 }
125
126 true
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn cjk_ideograph() {
135 assert!(is_cjk_char('使'));
136 assert!(is_cjk_char('用'));
137 assert!(is_cjk_char('进'));
138 }
139
140 #[test]
141 fn hiragana_katakana() {
142 assert!(is_cjk_char('あ'));
143 assert!(is_cjk_char('ア'));
144 }
145
146 #[test]
147 fn hangul() {
148 assert!(is_cjk_char('가'));
149 }
150
151 #[test]
152 fn latin_ascii_not_cjk() {
153 assert!(!is_cjk_char('a'));
154 assert!(!is_cjk_char('Z'));
155 assert!(!is_cjk_char('0'));
156 assert!(!is_cjk_char('-'));
157 }
158
159 #[test]
160 fn cjk_extension_a_boundary() {
161 assert!(is_cjk_char('\u{3400}')); assert!(is_cjk_char('\u{4DBF}')); assert!(!is_cjk_char('\u{33FF}')); assert!(!is_cjk_char('\u{4DC0}')); }
166
167 #[test]
168 fn unified_ideographs_boundary() {
169 assert!(is_cjk_char('\u{4E00}')); assert!(is_cjk_char('\u{9FFF}')); assert!(!is_cjk_char('\u{A000}')); }
173
174 #[test]
175 fn compatibility_ideographs_boundary() {
176 assert!(is_cjk_char('\u{F900}')); assert!(is_cjk_char('\u{FAFF}')); assert!(!is_cjk_char('\u{F8FF}')); assert!(!is_cjk_char('\u{FB00}')); }
181
182 #[test]
183 fn hiragana_boundary() {
184 assert!(is_cjk_char('\u{3040}')); assert!(is_cjk_char('\u{309F}')); assert!(!is_cjk_char('\u{303F}')); }
188
189 #[test]
190 fn katakana_boundary() {
191 assert!(is_cjk_char('\u{30A0}')); assert!(is_cjk_char('\u{30FF}')); assert!(!is_cjk_char('\u{3100}')); }
195
196 #[test]
197 fn hangul_boundary() {
198 assert!(is_cjk_char('\u{AC00}')); assert!(is_cjk_char('\u{D7AF}')); assert!(!is_cjk_char('\u{D7B0}')); }
202
203 #[test]
204 fn empty_string_no_cjk() {
205 assert!(!contains_cjk(""));
206 }
207
208 #[test]
209 fn all_latin_no_cjk() {
210 assert!(!contains_cjk("hello world"));
211 }
212
213 #[test]
214 fn all_cjk() {
215 assert!(contains_cjk("你好世界"));
216 }
217
218 #[test]
219 fn mixed_above_threshold() {
220 assert!(contains_cjk("abc你好"));
222 }
223
224 #[test]
225 fn mixed_below_threshold() {
226 assert!(!contains_cjk("abcdefghi你"));
228 }
229
230 #[test]
231 fn exactly_15_percent_is_false() {
232 let text = "你好世abcdefghijklmnopq"; assert_eq!(text.chars().count(), 20);
235 assert!(!contains_cjk(text));
236 }
237
238 #[test]
239 fn f32_classifier_preserves_strict_boundary_and_script_ranges() {
240 for (text, expected) in [
241 ("", false),
242 ("ordinary latin query", false),
243 ("漢字かな한글", true),
244 ("界界界abcdefghijklmnopq", false),
245 ("界界界abcdefghijklmnopqr", false),
246 ("界界界abcdefghijklmnop", true),
247 ] {
248 assert_eq!(contains_cjk_f32(text), expected, "text={text:?}");
249 }
250 }
251
252 #[test]
253 fn profile_pure_latin() {
254 let p = ScriptProfile::analyze("hello");
255 assert_eq!(p.char_count, 5);
256 assert_eq!(p.cjk_fraction, 0.0);
257 assert_eq!(p.latin_fraction, 1.0);
258 assert!(!p.is_cjk_dominant());
259 }
260
261 #[test]
262 fn profile_pure_cjk() {
263 let p = ScriptProfile::analyze("你好");
264 assert_eq!(p.char_count, 2);
265 assert_eq!(p.cjk_fraction, 1.0);
266 assert_eq!(p.latin_fraction, 0.0);
267 assert!(p.is_cjk_dominant());
268 }
269
270 #[test]
271 fn profile_empty() {
272 let p = ScriptProfile::analyze("");
273 assert_eq!(p.char_count, 0);
274 assert_eq!(p.cjk_fraction, 0.0);
275 assert!(!p.is_cjk_dominant());
276 }
277
278 #[test]
279 fn profile_mixed() {
280 let p = ScriptProfile::analyze("hi你");
282 assert_eq!(p.char_count, 3);
283 assert!((p.cjk_fraction - 1.0 / 3.0).abs() < 1e-9);
284 assert!((p.latin_fraction - 2.0 / 3.0).abs() < 1e-9);
285 assert!(p.is_cjk_dominant());
286 }
287
288 #[test]
289 fn empty_not_meaningful() {
290 assert!(!is_meaningful_query(""));
291 assert!(!is_meaningful_query(" "));
292 }
293
294 #[test]
295 fn symbols_only_not_meaningful() {
296 assert!(!is_meaningful_query("!!!"));
297 assert!(!is_meaningful_query("@#$%"));
298 assert!(!is_meaningful_query("..."));
299 }
300
301 #[test]
302 fn single_latin_char_not_meaningful() {
303 assert!(!is_meaningful_query("a"));
304 assert!(!is_meaningful_query("Z"));
305 }
306
307 #[test]
308 fn repeated_char_gibberish_not_meaningful() {
309 assert!(!is_meaningful_query("aaaaaaa")); assert!(!is_meaningful_query("aaaaab")); }
312
313 #[test]
314 fn repeated_char_below_threshold_is_meaningful() {
315 assert!(is_meaningful_query("aaab")); }
317
318 #[test]
319 fn normal_queries_are_meaningful() {
320 assert!(is_meaningful_query("rust programming"));
321 assert!(is_meaningful_query("你好世界"));
322 assert!(is_meaningful_query("BM25"));
323 assert!(is_meaningful_query("ab"));
324 }
325
326 #[test]
327 fn single_digit_is_meaningful() {
328 assert!(is_meaningful_query("5"));
330 }
331
332 #[test]
333 fn unicode_symbol_only_queries_are_not_meaningful() {
334 assert!(!is_meaningful_query("\u{FF0C}")); assert!(!is_meaningful_query("\u{3001}")); assert!(!is_meaningful_query("\u{FF01}\u{FF1F}")); assert!(!is_meaningful_query("\u{1F600}")); assert!(is_meaningful_query("\u{4F60}\u{597D}\u{4E16}\u{754C}")); assert!(is_meaningful_query("BM25"));
341 assert!(is_meaningful_query("5"));
342 }
343}