Skip to main content

context_forge/lexicon/
config.rs

1use std::collections::HashMap;
2use std::path::Path;
3use std::str::FromStr;
4
5use serde::{Deserialize, Serialize};
6
7use crate::entry::ContextEntry;
8use crate::{Error, Result};
9
10use super::LexiconScorer;
11
12/// TOML schema for [`ConfigLexiconScorer`].
13///
14/// ```toml
15/// [terms]
16/// "Omnissiah" = 1.3
17/// "Astartes"  = 1.4
18///
19/// [affirmations]
20/// patterns = ["for the emperor", "it shall be done"]
21///
22/// [negations]
23/// patterns = ["negative", "nay"]
24/// ```
25#[derive(Debug, Default, Clone, Serialize, Deserialize)]
26pub struct LexiconConfig {
27    /// Domain-specific terms mapped to their importance weight.
28    ///
29    /// Weight is an additive boost applied directly to the combined score via
30    /// `final_score = base × (1.0 + boost.clamp(-1.0, 2.0))`. A weight of `0.3`
31    /// adds 30% to the base score (1.3×); `1.0` doubles it (2.0×). Weights must
32    /// be in `(0.0, 1.5]` — the engine caps total boost at `2.0` (3.0× maximum).
33    #[serde(default)]
34    pub terms: HashMap<String, f64>,
35
36    /// Phrases that signal affirmation/confirmation in this persona's dialect.
37    /// Each match adds a fixed `+0.5` boost.
38    #[serde(default)]
39    pub affirmations: LexiconPatterns,
40
41    /// Phrases that signal negation/rejection. Each match subtracts `0.3`.
42    #[serde(default)]
43    pub negations: LexiconPatterns,
44}
45
46/// A list of case-insensitive substring patterns.
47#[derive(Debug, Default, Clone, Serialize, Deserialize)]
48pub struct LexiconPatterns {
49    /// The pattern strings to match against entry content.
50    #[serde(default)]
51    pub patterns: Vec<String>,
52}
53
54/// [`LexiconScorer`] backed by a TOML config file.
55///
56/// ## Matching
57///
58/// Pattern matching is **case-insensitive substring** with two normalizations
59/// applied before comparison:
60/// - Apostrophes are stripped from both content and pattern, so `"that's right"`
61///   matches `"thats right"` and vice versa.
62/// - A **3-token negation window** suppresses matches preceded within three
63///   whitespace-separated tokens by a negator (`not`, `never`, `don't`,
64///   `didn't`, `no`, `isn't`, `can't`, `cannot`, `won't`, `hardly`, `barely`).
65///   This prevents `"not confirmed"` from firing the `"confirmed"` affirmation.
66///
67/// ## Limitations
68///
69/// This is a **lexical heuristic, not a language model**. It does not perform
70/// syntactic parsing, sarcasm detection, discourse analysis, or full semantic
71/// interpretation. Known blind spots:
72/// - Deep negation stacking (`"I don't think this is not confirmed"`)
73/// - Pragmatic ambiguity (`"noted"` as genuine vs. cold acknowledgment)
74/// - Cross-sentence negation scope (the window is local to each pattern site)
75///
76/// The semantic search layer (embeddings) complements this scorer but does not
77/// replace it — embeddings handle vocabulary-agnostic retrieval; this scorer
78/// handles explicit memory-intent signals (commitments, decisions, corrections)
79/// that are not the same problem as semantic similarity.
80#[derive(Debug, Clone)]
81pub struct ConfigLexiconScorer {
82    config: LexiconConfig,
83}
84
85impl FromStr for ConfigLexiconScorer {
86    type Err = Error;
87
88    /// Parse a scorer from a TOML string. Also available as `str::parse::<ConfigLexiconScorer>()`.
89    ///
90    /// # Errors
91    ///
92    /// Returns an error if the TOML is malformed or doesn't match the
93    /// [`LexiconConfig`] schema.
94    fn from_str(s: &str) -> Result<Self> {
95        let config: LexiconConfig =
96            toml::from_str(s).map_err(|e| Error::Migration(format!("lexicon parse error: {e}")))?;
97
98        for (term, &weight) in &config.terms {
99            if weight <= 0.0 || weight > 1.5 {
100                return Err(Error::Migration(format!(
101                    "lexicon term {term:?} has weight {weight}, \
102                     but weights must be in (0.0, 1.5] \
103                     (engine caps total boost at 2.0, giving 3.0× maximum)"
104                )));
105            }
106        }
107
108        Ok(Self { config })
109    }
110}
111
112impl ConfigLexiconScorer {
113    /// Load a scorer from a TOML file on disk.
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if the file cannot be read or the TOML is invalid.
118    pub fn from_file(path: &Path) -> Result<Self> {
119        let toml = std::fs::read_to_string(path)
120            .map_err(|e| Error::Migration(format!("lexicon file error: {e}")))?;
121        toml.parse()
122    }
123}
124
125/// Negation words used by the 3-token window check. Content is already
126/// lowercased and apostrophe-stripped before this runs, so contractions
127/// appear without apostrophes (`dont`, `didnt`, etc.).
128const NEGATORS: &[&str] = &[
129    "not", "never", "no", "dont", "didnt", "isnt", "wasnt", "cant", "cannot", "wont", "hardly",
130    "barely",
131];
132
133/// Returns `true` if `match_start` in `content` is immediately preceded
134/// (within 3 whitespace-separated tokens) by a negation word.
135fn is_negated(content: &str, match_start: usize) -> bool {
136    let prefix = &content[..match_start];
137    let tokens: Vec<&str> = prefix.split_whitespace().collect();
138    let window_start = tokens.len().saturating_sub(3);
139    tokens[window_start..].iter().any(|t| {
140        let word = t.trim_end_matches(|c: char| c.is_ascii_punctuation());
141        NEGATORS.contains(&word)
142    })
143}
144
145/// Returns `true` if `pattern` appears in `content` at least once without
146/// being immediately preceded by a negation word.
147fn has_non_negated_match(content: &str, pattern: &str) -> bool {
148    let mut start = 0;
149    while let Some(rel) = content[start..].find(pattern) {
150        let pos = start + rel;
151        if !is_negated(content, pos) {
152            return true;
153        }
154        start = pos + 1;
155    }
156    false
157}
158
159impl LexiconScorer for ConfigLexiconScorer {
160    fn score(&self, entry: &ContextEntry, _query: &str) -> f32 {
161        // Normalize: lowercase + strip apostrophes so "that's right" matches "thats right".
162        let content = entry.content.to_lowercase().replace('\'', "");
163        let mut boost = 0.0_f64;
164
165        for (term, weight) in &self.config.terms {
166            let term_norm = term.to_lowercase().replace('\'', "");
167            if has_non_negated_match(&content, &term_norm) {
168                boost += weight;
169            }
170        }
171
172        for pattern in &self.config.affirmations.patterns {
173            let pat_norm = pattern.to_lowercase().replace('\'', "");
174            if has_non_negated_match(&content, &pat_norm) {
175                boost += 0.5;
176            }
177        }
178
179        for pattern in &self.config.negations.patterns {
180            let pat_norm = pattern.to_lowercase().replace('\'', "");
181            if has_non_negated_match(&content, &pat_norm) {
182                boost -= 0.3;
183            }
184        }
185
186        #[allow(
187            clippy::cast_possible_truncation,
188            reason = "boost is bounded by clamp; truncation is intentional"
189        )]
190        {
191            boost as f32
192        }
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use std::str::FromStr;
199
200    use super::*;
201    use crate::entry::kind;
202
203    fn entry(content: &str) -> ContextEntry {
204        ContextEntry {
205            id: "test".into(),
206            content: content.into(),
207            timestamp: 0,
208            kind: kind::MANUAL.to_owned(),
209            scope: None,
210            session_id: None,
211            token_count: None,
212            metadata: None,
213        }
214    }
215
216    const SAMPLE_TOML: &str = r#"
217[terms]
218"Omnissiah" = 1.3
219"Astartes"  = 1.4
220
221[affirmations]
222patterns = ["for the emperor", "it shall be done", "confirmed"]
223
224[negations]
225patterns = ["negative", "nay"]
226"#;
227
228    #[test]
229    fn from_str_parses_valid_toml() {
230        let scorer = ConfigLexiconScorer::from_str(SAMPLE_TOML).unwrap();
231        assert_eq!(scorer.config.terms.len(), 2);
232        assert_eq!(scorer.config.affirmations.patterns.len(), 3);
233        assert_eq!(scorer.config.negations.patterns.len(), 2);
234    }
235
236    #[test]
237    fn from_str_errors_on_malformed_toml() {
238        let result = ConfigLexiconScorer::from_str("[[[[not valid toml");
239        assert!(result.is_err());
240    }
241
242    #[test]
243    fn from_str_rejects_weight_above_max() {
244        let toml = "[terms]\n\"Heresy\" = 2.0";
245        let err = ConfigLexiconScorer::from_str(toml).unwrap_err();
246        let msg = err.to_string();
247        assert!(
248            msg.contains("Heresy"),
249            "error should name the offending term"
250        );
251        assert!(msg.contains('2'), "error should mention the invalid weight");
252    }
253
254    #[test]
255    fn from_str_rejects_nonpositive_weight() {
256        let toml = "[terms]\n\"Heresy\" = 0.0";
257        assert!(ConfigLexiconScorer::from_str(toml).is_err());
258    }
259
260    #[test]
261    fn from_str_accepts_weight_at_boundary() {
262        let toml = "[terms]\n\"Emperor\" = 1.5";
263        assert!(ConfigLexiconScorer::from_str(toml).is_ok());
264    }
265
266    #[test]
267    fn term_match_is_case_insensitive() {
268        let scorer = ConfigLexiconScorer::from_str(SAMPLE_TOML).unwrap();
269        let boost = scorer.score(&entry("the omnissiah guides our path"), "");
270        assert!(boost > 0.0, "expected positive boost, got {boost}");
271    }
272
273    #[test]
274    fn no_match_returns_zero() {
275        let scorer = ConfigLexiconScorer::from_str(SAMPLE_TOML).unwrap();
276        let boost = scorer.score(&entry("nothing relevant here"), "");
277        assert!(boost.abs() < f32::EPSILON);
278    }
279
280    #[test]
281    fn affirmation_adds_boost() {
282        let scorer = ConfigLexiconScorer::from_str(SAMPLE_TOML).unwrap();
283        let boost = scorer.score(&entry("for the emperor, we march"), "");
284        assert!(boost > 0.0);
285    }
286
287    #[test]
288    fn negation_reduces_boost() {
289        let scorer = ConfigLexiconScorer::from_str(SAMPLE_TOML).unwrap();
290        let boost = scorer.score(&entry("negative, we cannot proceed"), "");
291        assert!(boost < 0.0);
292    }
293
294    #[test]
295    fn empty_config_scores_zero() {
296        let scorer = ConfigLexiconScorer::from_str("").unwrap();
297        let boost = scorer.score(&entry("for the emperor and Astartes"), "");
298        assert!(boost.abs() < f32::EPSILON);
299    }
300
301    #[test]
302    fn negation_window_suppresses_negated_affirmation() {
303        let scorer = ConfigLexiconScorer::from_str(SAMPLE_TOML).unwrap();
304        let boost = scorer.score(&entry("that is not confirmed"), "");
305        assert!(
306            boost.abs() < f32::EPSILON,
307            "negated affirmation should score zero"
308        );
309    }
310
311    #[test]
312    fn unnegated_affirmation_still_boosts() {
313        let scorer = ConfigLexiconScorer::from_str(SAMPLE_TOML).unwrap();
314        let boost = scorer.score(&entry("yes that is confirmed"), "");
315        assert!(boost > 0.0, "un-negated affirmation should boost");
316    }
317
318    #[test]
319    fn negation_window_does_not_suppress_distant_negator() {
320        let scorer = ConfigLexiconScorer::from_str(SAMPLE_TOML).unwrap();
321        // "not" is 4 tokens before "confirmed" — outside the 3-token window
322        let boost = scorer.score(&entry("not sure about many things but confirmed"), "");
323        assert!(boost > 0.0, "negator outside window should not suppress");
324    }
325
326    #[test]
327    fn apostrophe_normalization_matches_contraction_variants() {
328        let scorer = ConfigLexiconScorer::from_str(SAMPLE_TOML).unwrap();
329        // "for the emperor" has no apostrophe but tests the normalization path
330        let boost_with = scorer.score(&entry("for the emperor, confirmed"), "");
331        assert!(boost_with > 0.0);
332    }
333}