1use std::collections::HashSet;
9
10#[derive(Debug, Clone, PartialEq)]
12pub enum SaveDecision {
13 Save,
14 Skip(String),
15}
16
17#[derive(Debug, Clone, PartialEq)]
19pub enum SearchDecision {
20 Search,
21 Skip(String),
22}
23
24#[derive(Debug, Clone)]
26pub struct GateConfig {
27 pub min_word_count: usize,
28 pub max_trivial_ratio: f32,
29 pub custom_trivial: Vec<String>,
30}
31
32impl Default for GateConfig {
33 fn default() -> Self {
34 Self {
35 min_word_count: 3,
36 max_trivial_ratio: 0.8,
37 custom_trivial: Vec::new(),
38 }
39 }
40}
41
42const TRIVIAL_PHRASES: &[&str] = &[
44 "ok", "yes", "no", "sure", "yep", "nope", "k", "yeah", "nah", "alright",
46 "right", "cool", "nice", "great", "perfect", "fine", "agreed", "understood",
47 "noted", "thanks", "ty", "thx", "lol", "lmao", "haha", "hm", "hmm", "ah",
48 "oh", "hey", "hi", "hello", "bye", "goodbye", "yo", "sup", "wow", "omg",
49 "brb", "gtg", "idk", "imo", "tbh", "smh", "ikr", "np", "gg", "ez", "rip",
50 "oof", "yikes", "meh", "duh", "oops", "ugh", "yay", "woo", "okay",
51 "got it", "sounds good", "makes sense", "that works", "no problem",
53 "no worries", "of course", "my bad", "my mistake", "will do",
54 "good point", "fair enough", "for sure", "all good", "thank you",
55 "good luck", "take care", "see ya", "you too", "same here",
56 "oh well", "oh no", "ha ha", "he he", "me too",
57 "ok sounds good", "yes thats right", "no thats wrong", "that makes sense",
59 "thats fine", "sure thing", "youre right", "i agree", "i see",
60 "i understand", "ok cool", "yep got it", "sounds great", "no doubt",
61 "for real", "oh i see", "ok thanks", "thanks a lot", "much appreciated",
62];
63
64const TRIVIAL_WORDS: &[&str] = &[
66 "ok", "yes", "no", "sure", "yeah", "nah", "right", "cool", "nice", "great",
67 "perfect", "fine", "thanks", "lol", "haha", "wow", "oh", "ah", "hmm", "hey",
68 "hi", "hello", "bye", "yo", "the", "a", "an", "i", "it", "is", "was", "and",
69 "or", "but", "so", "just", "very", "really", "too", "also", "well", "like",
70 "um", "uh",
71];
72
73fn normalize(text: &str) -> String {
76 let mut result = String::with_capacity(text.len());
77 let mut prev_space = true; for c in text.chars() {
79 if c.is_alphanumeric() {
80 for lc in c.to_lowercase() {
81 result.push(lc);
82 }
83 prev_space = false;
84 } else if c.is_whitespace() {
85 if !prev_space && !result.is_empty() {
86 result.push(' ');
87 prev_space = true;
88 }
89 }
90 }
91 if result.ends_with(' ') {
93 result.pop();
94 }
95 result
96}
97
98pub struct DecisionGate {
100 config: GateConfig,
101 trivial_phrases: HashSet<String>,
102 trivial_words: HashSet<String>,
103}
104
105impl DecisionGate {
106 pub fn new(config: GateConfig) -> Self {
107 let mut trivial_phrases: HashSet<String> =
108 TRIVIAL_PHRASES.iter().map(|s| s.to_string()).collect();
109 for phrase in &config.custom_trivial {
110 trivial_phrases.insert(normalize(phrase));
111 }
112 let trivial_words: HashSet<String> =
113 TRIVIAL_WORDS.iter().map(|s| s.to_string()).collect();
114 Self {
115 config,
116 trivial_phrases,
117 trivial_words,
118 }
119 }
120
121 pub fn should_save(&self, text: &str) -> SaveDecision {
123 match self.classify(text) {
124 Some(reason) => SaveDecision::Skip(reason),
125 None => SaveDecision::Save,
126 }
127 }
128
129 pub fn should_search(&self, text: &str) -> SearchDecision {
131 match self.classify(text) {
132 Some(reason) => SearchDecision::Skip(reason),
133 None => SearchDecision::Search,
134 }
135 }
136
137 fn classify(&self, text: &str) -> Option<String> {
139 let normalized = normalize(text);
140
141 if normalized.is_empty() {
143 return Some("empty input".to_string());
144 }
145
146 if self.trivial_phrases.contains(&normalized) {
148 return Some(format!("trivial phrase: {normalized}"));
149 }
150
151 let words: Vec<&str> = normalized.split_whitespace().collect();
153 if words.len() < self.config.min_word_count {
154 return Some(format!(
155 "too few words: {} < {}",
156 words.len(),
157 self.config.min_word_count
158 ));
159 }
160
161 let trivial_count = words
163 .iter()
164 .filter(|w| self.trivial_words.contains(**w))
165 .count();
166 let ratio = trivial_count as f32 / words.len() as f32;
167 if ratio > self.config.max_trivial_ratio {
168 return Some(format!(
169 "high trivial ratio: {ratio:.2} > {:.2}",
170 self.config.max_trivial_ratio
171 ));
172 }
173
174 None
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use std::time::Instant;
182
183 fn default_gate() -> DecisionGate {
184 DecisionGate::new(GateConfig::default())
185 }
186
187 #[test]
188 fn test_trivial_single_word_skip() {
189 let gate = default_gate();
190 assert!(matches!(gate.should_save("ok"), SaveDecision::Skip(_)));
191 assert!(matches!(gate.should_save("yes"), SaveDecision::Skip(_)));
192 assert!(matches!(gate.should_save("lol"), SaveDecision::Skip(_)));
193 }
194
195 #[test]
196 fn test_nontrivial_save() {
197 let gate = default_gate();
198 assert_eq!(
199 gate.should_save("Tell me about the deployment architecture"),
200 SaveDecision::Save
201 );
202 }
203
204 #[test]
205 fn test_search_trivial_skip() {
206 let gate = default_gate();
207 assert!(matches!(gate.should_search("ok"), SearchDecision::Skip(_)));
208 }
209
210 #[test]
211 fn test_search_nontrivial() {
212 let gate = default_gate();
213 assert_eq!(
214 gate.should_search("What were the Q4 revenue numbers?"),
215 SearchDecision::Search
216 );
217 }
218
219 #[test]
220 fn test_word_count_filter() {
221 let gate = default_gate();
222 assert!(matches!(gate.should_save("got"), SaveDecision::Skip(_)));
224 assert!(matches!(gate.should_save("I see"), SaveDecision::Skip(_)));
226 }
227
228 #[test]
229 fn test_trivial_ratio_filter() {
230 let gate = default_gate();
231 assert!(matches!(
236 gate.should_save("yes sure yeah cool"),
237 SaveDecision::Skip(_)
238 ));
239 }
248
249 #[test]
250 fn test_nontrivial_ratio_passes() {
251 let gate = default_gate();
252 assert_eq!(
253 gate.should_save("The deployment needs a new configuration"),
254 SaveDecision::Save
255 );
256 }
257
258 #[test]
259 fn test_custom_trivial_phrases() {
260 let config = GateConfig {
261 custom_trivial: vec!["roger that".to_string()],
262 ..GateConfig::default()
263 };
264 let gate = DecisionGate::new(config);
265 assert!(matches!(
266 gate.should_save("roger that"),
267 SaveDecision::Skip(_)
268 ));
269 }
270
271 #[test]
272 fn test_case_insensitive() {
273 let gate = default_gate();
274 assert!(matches!(gate.should_save("OK"), SaveDecision::Skip(_)));
275 assert!(matches!(gate.should_save("Thanks"), SaveDecision::Skip(_)));
276 assert!(matches!(gate.should_save("LOL"), SaveDecision::Skip(_)));
277 }
278
279 #[test]
280 fn test_whitespace_handling() {
281 let gate = default_gate();
282 assert!(matches!(
283 gate.should_save(" ok "),
284 SaveDecision::Skip(_)
285 ));
286 assert!(matches!(
288 gate.should_save(" hello world "),
289 SaveDecision::Skip(_)
290 ));
291 }
292
293 #[test]
294 fn test_empty_string() {
295 let gate = default_gate();
296 assert!(matches!(gate.should_save(""), SaveDecision::Skip(_)));
297 }
298
299 #[test]
300 fn test_gate_under_1_microsecond() {
301 let gate = default_gate();
302 let limit_us: u128 = if cfg!(debug_assertions) { 50_000 } else { 1_000 };
304
305 let start = Instant::now();
306 for _ in 0..1000 {
307 std::hint::black_box(gate.should_save("ok"));
308 }
309 let trivial_elapsed = start.elapsed();
310
311 let start = Instant::now();
312 for _ in 0..1000 {
313 std::hint::black_box(gate.should_save("Tell me about deployment architecture"));
314 }
315 let nontrivial_elapsed = start.elapsed();
316
317 assert!(
318 trivial_elapsed.as_micros() < limit_us,
319 "1000 trivial calls took {}µs, expected <{limit_us}µs",
320 trivial_elapsed.as_micros()
321 );
322 assert!(
323 nontrivial_elapsed.as_micros() < limit_us,
324 "1000 nontrivial calls took {}µs, expected <{limit_us}µs",
325 nontrivial_elapsed.as_micros()
326 );
327 }
328}