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 (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
30/// The 15% classifier using `f32` arithmetic for existing memory routing behavior.
31/// Near the boundary it can differ from [`contains_cjk`], which uses `f64`.
32pub 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/// Character-count profile containing CJK and ASCII-letter fractions.
47///
48/// See `crates/khive-text/docs/api/identifier-and-script.md`.
49#[derive(Debug, Clone, PartialEq)]
50pub struct ScriptProfile {
51    /// Fraction of characters that are CJK (0.0-1.0).
52    pub cjk_fraction: f64,
53    /// Fraction of characters that are ASCII letters (0.0-1.0).
54    pub latin_fraction: f64,
55    /// Total character count (not byte count).
56    pub char_count: usize,
57}
58
59impl ScriptProfile {
60    /// Analyze `text` and return a ScriptProfile.
61    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    /// True when CJK fraction exceeds 15%.
81    pub fn is_cjk_dominant(&self) -> bool {
82        self.cjk_fraction > 0.15
83    }
84}
85
86/// Returns true when `query` is worth sending to a retrieval backend.
87/// Rejects empty, symbol-only, single ASCII letter, and repeated-char (>80%) gibberish.
88///
89/// See `crates/khive-text/docs/api/identifier-and-script.md`.
90pub 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    // Symbol/punctuation/emoji-only queries are not meaningful, including Unicode symbols.
102    if !non_ws.iter().any(|c| c.is_alphanumeric()) {
103        return false;
104    }
105
106    // Single ASCII letter
107    if non_ws.len() == 1 && non_ws[0].is_ascii_alphabetic() {
108        return false;
109    }
110
111    // Repeated-char gibberish: dominant char > 80% of non-ws chars.
112    // Skip when total == 1 — a single character cannot exhibit gibberish repetition.
113    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}')); // first Extension A
162        assert!(is_cjk_char('\u{4DBF}')); // last Extension A
163        assert!(!is_cjk_char('\u{33FF}')); // just before
164        assert!(!is_cjk_char('\u{4DC0}')); // just after Extension A
165    }
166
167    #[test]
168    fn unified_ideographs_boundary() {
169        assert!(is_cjk_char('\u{4E00}')); // first CJK Unified
170        assert!(is_cjk_char('\u{9FFF}')); // last CJK Unified
171        assert!(!is_cjk_char('\u{A000}')); // just after
172    }
173
174    #[test]
175    fn compatibility_ideographs_boundary() {
176        assert!(is_cjk_char('\u{F900}')); // first Compatibility
177        assert!(is_cjk_char('\u{FAFF}')); // last Compatibility
178        assert!(!is_cjk_char('\u{F8FF}')); // just before
179        assert!(!is_cjk_char('\u{FB00}')); // just after
180    }
181
182    #[test]
183    fn hiragana_boundary() {
184        assert!(is_cjk_char('\u{3040}')); // first Hiragana
185        assert!(is_cjk_char('\u{309F}')); // last Hiragana
186        assert!(!is_cjk_char('\u{303F}')); // just before
187    }
188
189    #[test]
190    fn katakana_boundary() {
191        assert!(is_cjk_char('\u{30A0}')); // first Katakana
192        assert!(is_cjk_char('\u{30FF}')); // last Katakana
193        assert!(!is_cjk_char('\u{3100}')); // just after
194    }
195
196    #[test]
197    fn hangul_boundary() {
198        assert!(is_cjk_char('\u{AC00}')); // first Hangul Syllable
199        assert!(is_cjk_char('\u{D7AF}')); // last Hangul Syllable
200        assert!(!is_cjk_char('\u{D7B0}')); // just after
201    }
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        // 2 CJK in 5 chars = 40% > 15%
221        assert!(contains_cjk("abc你好"));
222    }
223
224    #[test]
225    fn mixed_below_threshold() {
226        // 1 CJK in 10 chars = 10% ≤ 15%
227        assert!(!contains_cjk("abcdefghi你"));
228    }
229
230    #[test]
231    fn exactly_15_percent_is_false() {
232        // 3 CJK in 20 chars = 15.0%, not > 15%
233        let text = "你好世abcdefghijklmnopq"; // 3 CJK + 17 latin = 20 chars
234        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        // "hi你" — 3 chars: 2 latin, 1 CJK => 33% CJK > 15%
281        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")); // 100%
310        assert!(!is_meaningful_query("aaaaab")); // 5/6 ≈ 83% > 80%
311    }
312
313    #[test]
314    fn repeated_char_below_threshold_is_meaningful() {
315        assert!(is_meaningful_query("aaab")); // 3/4 = 75% ≤ 80%
316    }
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        // Only single ASCII letter is blocked, not digit
329        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}")); // fullwidth comma
335        assert!(!is_meaningful_query("\u{3001}")); // ideographic comma
336        assert!(!is_meaningful_query("\u{FF01}\u{FF1F}")); // fullwidth ! ?
337        assert!(!is_meaningful_query("\u{1F600}")); // emoji-only
338
339        assert!(is_meaningful_query("\u{4F60}\u{597D}\u{4E16}\u{754C}")); // 你好世界
340        assert!(is_meaningful_query("BM25"));
341        assert!(is_meaningful_query("5"));
342    }
343}