Skip to main content

locus_sdk/application/
memory_lexical.rs

1use locus_core_rs::domain::models::SttpNode;
2
3use crate::domain::memory::{FallbackPolicy, StrictnessMode};
4
5/// Newest-first window scanned for natural-language term overlap.
6///
7/// This stays on the existing node query API. It does not add a text index.
8pub 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/// How `query_text` should affect an already-ranked recall set.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum LexicalActivation {
22    /// Leave the primary ranking untouched.
23    Skip,
24    /// Exact phrase match, used when fallback runs for a single token.
25    Legacy,
26    /// Content-term overlap for a multi-word question or phrase.
27    NaturalLanguage,
28}
29
30/// One content token plus the pieces of a hyphenated or underscored form.
31#[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
217/// Place lexical hits ahead of the primary ranking.
218///
219/// Without a query embedding, lexical hits replace resonance order.
220/// With an embedding, hits are prepended and the hybrid tail is kept.
221pub 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}