1use locus_core_rs::domain::models::SttpNode;
2
3use crate::domain::memory::{FallbackPolicy, StrictnessMode};
4
5pub const LEXICAL_SCAN_LIMIT: usize = 2000;
9
10const STOPWORDS: &[&str] = &[
11 "a", "an", "the", "and", "or", "but", "if", "then", "so", "of", "to", "for", "in", "on", "at",
12 "from", "with", "by", "as", "into", "over", "under", "about", "what", "which", "who", "whom",
13 "whose", "when", "where", "why", "how", "did", "do", "does", "is", "are", "was", "were", "be",
14 "been", "being", "am", "we", "i", "you", "he", "she", "they", "it", "me", "my", "our", "your",
15 "their", "them", "us", "this", "that", "these", "those", "there", "here", "please", "tell",
16 "just", "any", "some", "not", "remember", "recall", "know",
17];
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum LexicalActivation {
22 Skip,
24 Legacy,
26 NaturalLanguage,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct LexicalQuery {
33 pub phrase: String,
34 pub coverage: Vec<Vec<String>>,
35}
36
37impl LexicalQuery {
38 pub fn term_count(&self) -> usize {
39 self.coverage.len()
40 }
41
42 pub fn is_empty(&self) -> bool {
43 self.phrase.is_empty()
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct LexicalFields {
49 pub summary: bool,
50 pub tags: bool,
51 pub raw: bool,
52 pub session: bool,
53}
54
55impl LexicalFields {
56 pub const RECALL: Self = Self {
57 summary: true,
58 tags: true,
59 raw: true,
60 session: true,
61 };
62
63 pub const INVENTORY: Self = Self {
64 summary: true,
65 tags: true,
66 raw: false,
67 session: true,
68 };
69}
70
71pub fn parse_lexical_query(query_text: &str) -> LexicalQuery {
72 let phrase = query_text.trim().to_ascii_lowercase();
73 let mut coverage = Vec::new();
74
75 for raw in phrase.split(|c: char| !c.is_alphanumeric() && c != '-' && c != '_') {
76 let token = raw.trim_matches(|c: char| c == '-' || c == '_').to_string();
77 if token.len() < 2 || is_stopword(&token) {
78 continue;
79 }
80
81 let mut variants = vec![token.clone()];
82 let parts = token
83 .split(|c: char| c == '-' || c == '_')
84 .filter(|part| part.len() >= 2 && !is_stopword(part))
85 .map(str::to_string)
86 .collect::<Vec<_>>();
87 if parts.len() > 1 {
88 for part in parts {
89 if !variants.iter().any(|existing| existing == &part) {
90 variants.push(part);
91 }
92 }
93 }
94
95 if coverage.iter().any(|group: &Vec<String>| group[0] == token) {
96 continue;
97 }
98 coverage.push(variants);
99 }
100
101 LexicalQuery { phrase, coverage }
102}
103
104pub fn activation(
105 policy: FallbackPolicy,
106 query_text: &str,
107 primary_empty: bool,
108) -> LexicalActivation {
109 let query = parse_lexical_query(query_text);
110 let natural = query.term_count() >= 2;
111
112 match policy {
113 FallbackPolicy::Never => LexicalActivation::Skip,
114 FallbackPolicy::OnEmpty if natural => LexicalActivation::NaturalLanguage,
115 FallbackPolicy::OnEmpty if primary_empty && !query.phrase.is_empty() => {
116 LexicalActivation::Legacy
117 }
118 FallbackPolicy::OnEmpty => LexicalActivation::Skip,
119 FallbackPolicy::Always if natural => LexicalActivation::NaturalLanguage,
120 FallbackPolicy::Always if !query.phrase.is_empty() => LexicalActivation::Legacy,
121 FallbackPolicy::Always => LexicalActivation::Skip,
122 }
123}
124
125pub fn required_term_hits(term_count: usize, strictness: StrictnessMode) -> usize {
126 if term_count == 0 {
127 return 1;
128 }
129
130 match strictness {
131 StrictnessMode::Precision => term_count,
132 StrictnessMode::Balanced => term_count.div_ceil(2),
133 StrictnessMode::Recall => 1,
134 }
135}
136
137pub fn select_lexical_matches(
138 nodes: Vec<SttpNode>,
139 query: &LexicalQuery,
140 strictness: StrictnessMode,
141 fields: LexicalFields,
142) -> Vec<SttpNode> {
143 if query.term_count() == 0 {
144 return Vec::new();
145 }
146
147 let required = required_term_hits(query.term_count(), strictness);
148 let mut scored = nodes
149 .into_iter()
150 .filter_map(|node| {
151 let (score, hits) = score_node(&node, query, fields);
152 if hits >= required && score > 0 {
153 Some((score, node.timestamp, node))
154 } else {
155 None
156 }
157 })
158 .collect::<Vec<_>>();
159
160 scored.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(&left.1)));
161 scored.into_iter().map(|(_, _, node)| node).collect()
162}
163
164pub fn legacy_phrase_filter(nodes: Vec<SttpNode>, query_text: &str) -> Vec<SttpNode> {
165 let needle = query_text.trim().to_ascii_lowercase();
166 if needle.is_empty() {
167 return nodes;
168 }
169
170 let mut scored = nodes
171 .into_iter()
172 .filter_map(|node| {
173 let summary = node
174 .context_summary
175 .as_deref()
176 .unwrap_or_default()
177 .to_ascii_lowercase();
178 let session = node.session_id.to_ascii_lowercase();
179 let raw = node.raw.to_ascii_lowercase();
180
181 let mut score = 0usize;
182 if summary.contains(&needle) {
183 score += 3;
184 }
185 if session.contains(&needle) {
186 score += 2;
187 }
188 if raw.contains(&needle) {
189 score += 1;
190 }
191
192 if score > 0 {
193 Some((score, node.timestamp, node))
194 } else {
195 None
196 }
197 })
198 .collect::<Vec<_>>();
199
200 scored.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(&left.1)));
201 scored.into_iter().map(|(_, _, node)| node).collect()
202}
203
204pub fn merge_unique(primary: Vec<SttpNode>, secondary: Vec<SttpNode>) -> Vec<SttpNode> {
205 let mut merged = Vec::with_capacity(primary.len() + secondary.len());
206 let mut seen = std::collections::HashSet::new();
207
208 for node in primary.into_iter().chain(secondary.into_iter()) {
209 if seen.insert(node.sync_key.clone()) {
210 merged.push(node);
211 }
212 }
213
214 merged
215}
216
217pub fn apply_natural_language(
222 primary: Vec<SttpNode>,
223 lexical_matches: Vec<SttpNode>,
224 has_query_embedding: bool,
225) -> (Vec<SttpNode>, bool) {
226 if lexical_matches.is_empty() {
227 return (primary, false);
228 }
229
230 if has_query_embedding {
231 (merge_unique(lexical_matches, primary), true)
232 } else {
233 (lexical_matches, true)
234 }
235}
236
237fn score_node(node: &SttpNode, query: &LexicalQuery, fields: LexicalFields) -> (u32, usize) {
238 let summary = if fields.summary {
239 node.context_summary
240 .as_deref()
241 .unwrap_or_default()
242 .to_ascii_lowercase()
243 } else {
244 String::new()
245 };
246 let raw = if fields.raw {
247 node.raw.to_ascii_lowercase()
248 } else {
249 String::new()
250 };
251 let session = if fields.session {
252 node.session_id.to_ascii_lowercase()
253 } else {
254 String::new()
255 };
256 let tags = if fields.tags {
257 node.semantic_tags
258 .as_deref()
259 .unwrap_or_default()
260 .iter()
261 .map(|tag| tag.to_ascii_lowercase())
262 .collect::<Vec<_>>()
263 } else {
264 Vec::new()
265 };
266
267 let mut score = 0u32;
268 let mut hits = 0usize;
269
270 for variants in &query.coverage {
271 let mut best = 0u32;
272 for variant in variants {
273 best = best.max(variant_score(
274 variant, &summary, &raw, &session, &tags, fields,
275 ));
276 }
277 if best > 0 {
278 hits += 1;
279 score += best;
280 }
281 }
282
283 if query.phrase.contains(char::is_whitespace) {
284 if fields.summary && summary.contains(&query.phrase) {
285 score += 8;
286 }
287 if fields.raw && raw.contains(&query.phrase) {
288 score += 4;
289 }
290 }
291
292 (score, hits)
293}
294
295fn variant_score(
296 term: &str,
297 summary: &str,
298 raw: &str,
299 session: &str,
300 tags: &[String],
301 fields: LexicalFields,
302) -> u32 {
303 let mut score = 0u32;
304
305 if fields.summary && field_contains_term(summary, term) {
306 score += 3;
307 }
308 if fields.tags {
309 if tags.iter().any(|tag| tag == term) {
310 score += 5;
311 } else if tags.iter().any(|tag| field_contains_term(tag, term)) {
312 score += 3;
313 }
314 }
315 if fields.raw && term.len() >= 4 && field_contains_term(raw, term) {
316 score += 1;
317 }
318 if fields.session && term.len() >= 4 && field_contains_term(session, term) {
319 score += 2;
320 }
321
322 score
323}
324
325fn field_contains_term(field: &str, term: &str) -> bool {
326 if term.is_empty() {
327 return false;
328 }
329 if field.contains(term) {
330 return true;
331 }
332 if term.len() < 5 {
333 return false;
334 }
335
336 field.split(|c: char| !c.is_alphanumeric()).any(|word| {
337 if word.len() < 5 {
338 return false;
339 }
340 word.starts_with(term) || term.starts_with(word)
341 })
342}
343
344fn is_stopword(token: &str) -> bool {
345 STOPWORDS.contains(&token)
346}
347
348#[cfg(test)]
349mod tests {
350 use super::{
351 LexicalFields, activation, parse_lexical_query, required_term_hits, select_lexical_matches,
352 };
353 use crate::domain::memory::{FallbackPolicy, StrictnessMode};
354 use chrono::Utc;
355 use locus_core_rs::domain::models::{AvecState, SttpNode};
356
357 #[test]
358 fn question_drops_stopwords_and_keeps_content_terms() {
359 let query = parse_lexical_query("what did we decide about the parser grammar?");
360 let surface = query
361 .coverage
362 .iter()
363 .map(|variants| variants[0].as_str())
364 .collect::<Vec<_>>();
365 assert_eq!(surface, vec!["decide", "parser", "grammar"]);
366 }
367
368 #[test]
369 fn hyphenated_token_keeps_compound_and_parts() {
370 let query = parse_lexical_query("strict-mode");
371 assert_eq!(
372 query.coverage,
373 vec![vec![
374 "strict-mode".to_string(),
375 "strict".to_string(),
376 "mode".to_string()
377 ]]
378 );
379 }
380
381 #[test]
382 fn natural_language_activates_even_when_primary_is_non_empty() {
383 assert_eq!(
384 activation(
385 FallbackPolicy::OnEmpty,
386 "what did we decide about parser grammar",
387 false
388 ),
389 super::LexicalActivation::NaturalLanguage
390 );
391 assert_eq!(
392 activation(FallbackPolicy::OnEmpty, "parser", false),
393 super::LexicalActivation::Skip
394 );
395 assert_eq!(
396 activation(
397 FallbackPolicy::Never,
398 "what did we decide about parser grammar",
399 true
400 ),
401 super::LexicalActivation::Skip
402 );
403 }
404
405 #[test]
406 fn inflection_and_phrase_bonus_rank_the_closer_summary_first() {
407 let query = parse_lexical_query("what did we decide about the parser grammar");
408 let decided = node(
409 "decided",
410 "decided to harden the parser grammar",
411 "notes",
412 None,
413 );
414 let partial = node(
415 "partial",
416 "parser grammar notes from standup",
417 "other",
418 None,
419 );
420 let ranked = select_lexical_matches(
421 vec![partial, decided],
422 &query,
423 StrictnessMode::Balanced,
424 LexicalFields::RECALL,
425 );
426
427 assert_eq!(ranked.len(), 2);
428 assert_eq!(ranked[0].sync_key, "decided");
429 }
430
431 #[test]
432 fn precision_requires_every_content_term() {
433 let query = parse_lexical_query("parser grammar rollout");
434 assert_eq!(
435 required_term_hits(query.term_count(), StrictnessMode::Precision),
436 3
437 );
438 let partial = node("partial", "parser notes", "parser", None);
439 let ranked = select_lexical_matches(
440 vec![partial],
441 &query,
442 StrictnessMode::Precision,
443 LexicalFields::RECALL,
444 );
445 assert!(ranked.is_empty());
446 }
447
448 fn node(sync_key: &str, summary: &str, raw: &str, tags: Option<Vec<&str>>) -> SttpNode {
449 let now = Utc::now();
450 let avec = AvecState::zero();
451 SttpNode {
452 raw: raw.to_string(),
453 session_id: "session".to_string(),
454 tier: "raw".to_string(),
455 timestamp: now,
456 compression_depth: 1,
457 parent_node_id: None,
458 sync_key: sync_key.to_string(),
459 updated_at: now,
460 source_metadata: None,
461 context_summary: Some(summary.to_string()),
462 semantic_tags: tags.map(|values| values.into_iter().map(str::to_string).collect()),
463 semantic_links: None,
464 embedding_dimensions: None,
465 embedding_model: None,
466 embedding: None,
467 embedded_at: None,
468 user_avec: avec,
469 model_avec: avec,
470 compression_avec: Some(avec),
471 rho: 0.5,
472 kappa: 0.5,
473 psi: 1.0,
474 }
475 }
476}