Skip to main content

khive_text/
lang.rs

1//! Script/alphabet identification for per-language routing decisions.
2
3use std::collections::HashMap;
4
5/// Return whether `c` is in a supported CJK, Hiragana, Katakana, or Hangul block.
6///
7/// See `crates/khive-text/docs/api/identifier-and-script.md`.
8#[inline]
9pub fn is_cjk_char(c: char) -> bool {
10    matches!(c,
11        '\u{3040}'..='\u{309F}'     // Hiragana
12        | '\u{30A0}'..='\u{30FF}'   // Katakana
13        | '\u{3400}'..='\u{4DBF}'   // CJK Extension A
14        | '\u{4E00}'..='\u{9FFF}'   // CJK Unified Ideographs
15        | '\u{F900}'..='\u{FAFF}'   // CJK Compatibility Ideographs
16        | '\u{AC00}'..='\u{D7AF}'   // Hangul Syllables
17        | '\u{20000}'..='\u{2A6DF}' // CJK Extension B
18    )
19}
20
21/// Return whether strictly more than 15% of `text` characters are CJK.
22pub fn contains_cjk(text: &str) -> bool {
23    let chars: Vec<char> = text.chars().collect();
24    if chars.is_empty() {
25        return false;
26    }
27    let cjk_count = chars.iter().filter(|&&c| is_cjk_char(c)).count();
28    (cjk_count as f64 / chars.len() as f64) > 0.15
29}
30
31/// Character-count profile containing CJK and ASCII-letter fractions.
32///
33/// See `crates/khive-text/docs/api/identifier-and-script.md`.
34#[derive(Debug, Clone, PartialEq)]
35pub struct ScriptProfile {
36    /// Fraction of characters that are CJK (0.0-1.0).
37    pub cjk_fraction: f64,
38    /// Fraction of characters that are ASCII letters (0.0-1.0).
39    pub latin_fraction: f64,
40    /// Total character count (not byte count).
41    pub char_count: usize,
42}
43
44impl ScriptProfile {
45    /// Analyze `text` and return a ScriptProfile.
46    pub fn analyze(text: &str) -> Self {
47        let chars: Vec<char> = text.chars().collect();
48        let n = chars.len();
49        if n == 0 {
50            return Self {
51                cjk_fraction: 0.0,
52                latin_fraction: 0.0,
53                char_count: 0,
54            };
55        }
56        let cjk = chars.iter().filter(|&&c| is_cjk_char(c)).count();
57        let latin = chars.iter().filter(|&&c| c.is_ascii_alphabetic()).count();
58        Self {
59            cjk_fraction: cjk as f64 / n as f64,
60            latin_fraction: latin as f64 / n as f64,
61            char_count: n,
62        }
63    }
64
65    /// True when CJK fraction exceeds 15%.
66    pub fn is_cjk_dominant(&self) -> bool {
67        self.cjk_fraction > 0.15
68    }
69}
70
71/// Returns true when `query` is worth sending to a retrieval backend.
72/// Rejects empty, symbol-only, single ASCII letter, and repeated-char (>80%) gibberish.
73///
74/// See `crates/khive-text/docs/api/identifier-and-script.md`.
75pub fn is_meaningful_query(query: &str) -> bool {
76    let trimmed = query.trim();
77    if trimmed.is_empty() {
78        return false;
79    }
80
81    let non_ws: Vec<char> = trimmed.chars().filter(|c| !c.is_whitespace()).collect();
82    if non_ws.is_empty() {
83        return false;
84    }
85
86    // Symbol/punctuation/emoji-only queries are not meaningful, including Unicode symbols.
87    if !non_ws.iter().any(|c| c.is_alphanumeric()) {
88        return false;
89    }
90
91    // Single ASCII letter
92    if non_ws.len() == 1 && non_ws[0].is_ascii_alphabetic() {
93        return false;
94    }
95
96    // Repeated-char gibberish: dominant char > 80% of non-ws chars.
97    // Skip when total == 1 — a single character cannot exhibit gibberish repetition.
98    let total = non_ws.len();
99    if total > 1 {
100        let mut counts: HashMap<char, usize> = HashMap::new();
101        for c in &non_ws {
102            *counts.entry(*c).or_insert(0) += 1;
103        }
104        if let Some(&max_count) = counts.values().max() {
105            if max_count as f64 / total as f64 > 0.80 {
106                return false;
107            }
108        }
109    }
110
111    true
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn cjk_ideograph() {
120        assert!(is_cjk_char('使'));
121        assert!(is_cjk_char('用'));
122        assert!(is_cjk_char('进'));
123    }
124
125    #[test]
126    fn hiragana_katakana() {
127        assert!(is_cjk_char('あ'));
128        assert!(is_cjk_char('ア'));
129    }
130
131    #[test]
132    fn hangul() {
133        assert!(is_cjk_char('가'));
134    }
135
136    #[test]
137    fn latin_ascii_not_cjk() {
138        assert!(!is_cjk_char('a'));
139        assert!(!is_cjk_char('Z'));
140        assert!(!is_cjk_char('0'));
141        assert!(!is_cjk_char('-'));
142    }
143
144    #[test]
145    fn cjk_extension_a_boundary() {
146        assert!(is_cjk_char('\u{3400}')); // first Extension A
147        assert!(is_cjk_char('\u{4DBF}')); // last Extension A
148        assert!(!is_cjk_char('\u{33FF}')); // just before
149        assert!(!is_cjk_char('\u{4DC0}')); // just after Extension A
150    }
151
152    #[test]
153    fn unified_ideographs_boundary() {
154        assert!(is_cjk_char('\u{4E00}')); // first CJK Unified
155        assert!(is_cjk_char('\u{9FFF}')); // last CJK Unified
156        assert!(!is_cjk_char('\u{A000}')); // just after
157    }
158
159    #[test]
160    fn compatibility_ideographs_boundary() {
161        assert!(is_cjk_char('\u{F900}')); // first Compatibility
162        assert!(is_cjk_char('\u{FAFF}')); // last Compatibility
163        assert!(!is_cjk_char('\u{F8FF}')); // just before
164        assert!(!is_cjk_char('\u{FB00}')); // just after
165    }
166
167    #[test]
168    fn hiragana_boundary() {
169        assert!(is_cjk_char('\u{3040}')); // first Hiragana
170        assert!(is_cjk_char('\u{309F}')); // last Hiragana
171        assert!(!is_cjk_char('\u{303F}')); // just before
172    }
173
174    #[test]
175    fn katakana_boundary() {
176        assert!(is_cjk_char('\u{30A0}')); // first Katakana
177        assert!(is_cjk_char('\u{30FF}')); // last Katakana
178        assert!(!is_cjk_char('\u{3100}')); // just after
179    }
180
181    #[test]
182    fn hangul_boundary() {
183        assert!(is_cjk_char('\u{AC00}')); // first Hangul Syllable
184        assert!(is_cjk_char('\u{D7AF}')); // last Hangul Syllable
185        assert!(!is_cjk_char('\u{D7B0}')); // just after
186    }
187
188    #[test]
189    fn empty_string_no_cjk() {
190        assert!(!contains_cjk(""));
191    }
192
193    #[test]
194    fn all_latin_no_cjk() {
195        assert!(!contains_cjk("hello world"));
196    }
197
198    #[test]
199    fn all_cjk() {
200        assert!(contains_cjk("你好世界"));
201    }
202
203    #[test]
204    fn mixed_above_threshold() {
205        // 2 CJK in 5 chars = 40% > 15%
206        assert!(contains_cjk("abc你好"));
207    }
208
209    #[test]
210    fn mixed_below_threshold() {
211        // 1 CJK in 10 chars = 10% ≤ 15%
212        assert!(!contains_cjk("abcdefghi你"));
213    }
214
215    #[test]
216    fn exactly_15_percent_is_false() {
217        // 3 CJK in 20 chars = 15.0%, not > 15%
218        let text = "你好世abcdefghijklmnopq"; // 3 CJK + 17 latin = 20 chars
219        assert_eq!(text.chars().count(), 20);
220        assert!(!contains_cjk(text));
221    }
222
223    #[test]
224    fn profile_pure_latin() {
225        let p = ScriptProfile::analyze("hello");
226        assert_eq!(p.char_count, 5);
227        assert_eq!(p.cjk_fraction, 0.0);
228        assert_eq!(p.latin_fraction, 1.0);
229        assert!(!p.is_cjk_dominant());
230    }
231
232    #[test]
233    fn profile_pure_cjk() {
234        let p = ScriptProfile::analyze("你好");
235        assert_eq!(p.char_count, 2);
236        assert_eq!(p.cjk_fraction, 1.0);
237        assert_eq!(p.latin_fraction, 0.0);
238        assert!(p.is_cjk_dominant());
239    }
240
241    #[test]
242    fn profile_empty() {
243        let p = ScriptProfile::analyze("");
244        assert_eq!(p.char_count, 0);
245        assert_eq!(p.cjk_fraction, 0.0);
246        assert!(!p.is_cjk_dominant());
247    }
248
249    #[test]
250    fn profile_mixed() {
251        // "hi你" — 3 chars: 2 latin, 1 CJK => 33% CJK > 15%
252        let p = ScriptProfile::analyze("hi你");
253        assert_eq!(p.char_count, 3);
254        assert!((p.cjk_fraction - 1.0 / 3.0).abs() < 1e-9);
255        assert!((p.latin_fraction - 2.0 / 3.0).abs() < 1e-9);
256        assert!(p.is_cjk_dominant());
257    }
258
259    #[test]
260    fn empty_not_meaningful() {
261        assert!(!is_meaningful_query(""));
262        assert!(!is_meaningful_query("   "));
263    }
264
265    #[test]
266    fn symbols_only_not_meaningful() {
267        assert!(!is_meaningful_query("!!!"));
268        assert!(!is_meaningful_query("@#$%"));
269        assert!(!is_meaningful_query("..."));
270    }
271
272    #[test]
273    fn single_latin_char_not_meaningful() {
274        assert!(!is_meaningful_query("a"));
275        assert!(!is_meaningful_query("Z"));
276    }
277
278    #[test]
279    fn repeated_char_gibberish_not_meaningful() {
280        assert!(!is_meaningful_query("aaaaaaa")); // 100%
281        assert!(!is_meaningful_query("aaaaab")); // 5/6 ≈ 83% > 80%
282    }
283
284    #[test]
285    fn repeated_char_below_threshold_is_meaningful() {
286        assert!(is_meaningful_query("aaab")); // 3/4 = 75% ≤ 80%
287    }
288
289    #[test]
290    fn normal_queries_are_meaningful() {
291        assert!(is_meaningful_query("rust programming"));
292        assert!(is_meaningful_query("你好世界"));
293        assert!(is_meaningful_query("BM25"));
294        assert!(is_meaningful_query("ab"));
295    }
296
297    #[test]
298    fn single_digit_is_meaningful() {
299        // Only single ASCII letter is blocked, not digit
300        assert!(is_meaningful_query("5"));
301    }
302
303    #[test]
304    fn unicode_symbol_only_queries_are_not_meaningful() {
305        assert!(!is_meaningful_query("\u{FF0C}")); // fullwidth comma
306        assert!(!is_meaningful_query("\u{3001}")); // ideographic comma
307        assert!(!is_meaningful_query("\u{FF01}\u{FF1F}")); // fullwidth ! ?
308        assert!(!is_meaningful_query("\u{1F600}")); // emoji-only
309
310        assert!(is_meaningful_query("\u{4F60}\u{597D}\u{4E16}\u{754C}")); // 你好世界
311        assert!(is_meaningful_query("BM25"));
312        assert!(is_meaningful_query("5"));
313    }
314}