deepstrike_core/
lexical.rs1use std::collections::BTreeSet;
13
14pub fn terms(text: &str) -> BTreeSet<String> {
25 let mut output = BTreeSet::new();
26 let mut ascii_run = String::new();
27 let mut wide_run: Vec<char> = Vec::new();
28 for character in text.chars().flat_map(char::to_lowercase) {
29 if character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '/' | '.' | ':') {
30 flush_wide(&mut wide_run, &mut output);
31 ascii_run.push(character);
32 } else if !character.is_ascii() && character.is_alphanumeric() {
33 flush_ascii(&mut ascii_run, &mut output);
34 wide_run.push(character);
35 } else {
36 flush_ascii(&mut ascii_run, &mut output);
37 flush_wide(&mut wide_run, &mut output);
38 }
39 }
40 flush_ascii(&mut ascii_run, &mut output);
41 flush_wide(&mut wide_run, &mut output);
42 output
43}
44
45pub fn overlap_count(left: &BTreeSet<String>, right: &BTreeSet<String>) -> u32 {
47 left.intersection(right).count() as u32
48}
49
50pub fn jaccard(left: &str, right: &str) -> f64 {
56 let left = terms(left);
57 let right = terms(right);
58 let union = left.union(&right).count();
59 if union == 0 {
60 return 0.0;
61 }
62 left.intersection(&right).count() as f64 / union as f64
63}
64
65fn flush_ascii(run: &mut String, output: &mut BTreeSet<String>) {
66 if run.len() > 1 {
68 output.insert(std::mem::take(run));
69 } else {
70 run.clear();
71 }
72}
73
74fn flush_wide(run: &mut Vec<char>, output: &mut BTreeSet<String>) {
75 match run.len() {
76 0 => return,
77 1 => {
78 output.insert(run[0].to_string());
79 }
80 _ => {
81 output.insert(run.iter().collect());
82 if run.iter().copied().any(is_han) {
83 for pair in run.windows(2) {
84 output.insert(pair.iter().collect());
85 }
86 }
87 }
88 }
89 run.clear();
90}
91
92fn is_han(character: char) -> bool {
94 matches!(
95 u32::from(character),
96 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0x20000..=0x3134F
97 )
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn ascii_words_paths_and_ids_stay_whole() {
106 let output = terms("Read src/main.rs via call_abc-123, then STOP.");
107 assert!(output.contains("read"));
108 assert!(output.contains("src/main.rs"));
109 assert!(output.contains("call_abc-123"));
110 assert!(!output.contains("a"));
112 assert!(!output.contains(","));
113 }
114
115 #[test]
116 fn han_runs_emit_bigrams_not_unigrams() {
117 let output = terms("实现用户登录");
118 assert!(output.contains("实现"));
119 assert!(output.contains("用户"));
120 assert!(output.contains("户登"));
121 assert!(output.contains("实现用户登录"));
122 assert!(!output.contains("实"), "unigram noise must be gone");
123 }
124
125 #[test]
126 fn chinese_goal_overlap_discriminates() {
127 let goal = terms("实现用户登录功能");
128 let on_topic = terms("已完成用户登录表单");
129 let off_topic = terms("今天天气很好我们去公园");
130 assert!(overlap_count(&goal, &on_topic) >= 2);
131 assert_eq!(overlap_count(&goal, &off_topic), 0);
132 }
133
134 #[test]
135 fn fullwidth_punctuation_is_not_a_term() {
136 let output = terms("完成了。下一步:测试!");
137 assert!(!output.contains("。"));
138 assert!(!output.contains(":"));
139 assert!(output.contains("完成"));
140 }
141
142 #[test]
143 fn lone_wide_char_is_kept() {
144 assert!(terms("改 a").contains("改"));
145 }
146
147 #[test]
148 fn mixed_ascii_and_han_split_into_both_vocabularies() {
149 let output = terms("部署v2服务到prod环境");
150 assert!(output.contains("v2"));
151 assert!(output.contains("prod"));
152 assert!(output.contains("部署"));
153 assert!(output.contains("服务"));
154 assert!(output.contains("环境"));
155 }
156
157 #[test]
158 fn jaccard_near_duplicate_chinese_scores_high() {
159 let near = jaccard("用户偏好深色模式界面", "用户偏好浅色模式界面");
160 let unrelated = jaccard("用户偏好深色模式界面", "周五之前完成部署上线");
161 assert!(near > 0.5, "near-duplicates must be detectable: {near}");
162 assert!(unrelated < 0.2, "unrelated must stay low: {unrelated}");
163 }
164
165 #[test]
166 fn jaccard_identical_english_is_one_and_empty_vocabulary_is_zero() {
167 assert_eq!(jaccard("prefer cargo nextest", "prefer cargo nextest"), 1.0);
168 assert_eq!(jaccard("", ""), 0.0);
169 }
170
171 #[test]
172 fn non_han_scripts_keep_whole_words_without_bigrams() {
173 let output = terms("привет мир");
174 assert!(output.contains("привет"));
175 assert!(output.contains("мир"));
176 assert!(!output.contains("пр"));
177 }
178}