context_forge/lexicon/
config.rs1use 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#[derive(Debug, Default, Clone, Serialize, Deserialize)]
26pub struct LexiconConfig {
27 #[serde(default)]
34 pub terms: HashMap<String, f64>,
35
36 #[serde(default)]
39 pub affirmations: LexiconPatterns,
40
41 #[serde(default)]
43 pub negations: LexiconPatterns,
44}
45
46#[derive(Debug, Default, Clone, Serialize, Deserialize)]
48pub struct LexiconPatterns {
49 #[serde(default)]
51 pub patterns: Vec<String>,
52}
53
54#[derive(Debug, Clone)]
81pub struct ConfigLexiconScorer {
82 config: LexiconConfig,
83}
84
85impl FromStr for ConfigLexiconScorer {
86 type Err = Error;
87
88 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 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
125const NEGATORS: &[&str] = &[
129 "not", "never", "no", "dont", "didnt", "isnt", "wasnt", "cant", "cannot", "wont", "hardly",
130 "barely",
131];
132
133fn 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
145fn 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 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 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 let boost_with = scorer.score(&entry("for the emperor, confirmed"), "");
331 assert!(boost_with > 0.0);
332 }
333}