use std::collections::HashMap;
use std::path::Path;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use crate::entry::ContextEntry;
use crate::{Error, Result};
use super::LexiconScorer;
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct LexiconConfig {
#[serde(default)]
pub terms: HashMap<String, f64>,
#[serde(default)]
pub affirmations: LexiconPatterns,
#[serde(default)]
pub negations: LexiconPatterns,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct LexiconPatterns {
#[serde(default)]
pub patterns: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct ConfigLexiconScorer {
config: LexiconConfig,
}
impl FromStr for ConfigLexiconScorer {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
let config: LexiconConfig =
toml::from_str(s).map_err(|e| Error::Migration(format!("lexicon parse error: {e}")))?;
for (term, &weight) in &config.terms {
if weight <= 0.0 || weight > 1.5 {
return Err(Error::Migration(format!(
"lexicon term {term:?} has weight {weight}, \
but weights must be in (0.0, 1.5] \
(engine caps total boost at 2.0, giving 3.0× maximum)"
)));
}
}
Ok(Self { config })
}
}
impl ConfigLexiconScorer {
pub fn from_file(path: &Path) -> Result<Self> {
let toml = std::fs::read_to_string(path)
.map_err(|e| Error::Migration(format!("lexicon file error: {e}")))?;
toml.parse()
}
}
const NEGATORS: &[&str] = &[
"not", "never", "no", "dont", "didnt", "isnt", "wasnt", "cant", "cannot", "wont", "hardly",
"barely",
];
fn is_negated(content: &str, match_start: usize) -> bool {
let prefix = &content[..match_start];
let tokens: Vec<&str> = prefix.split_whitespace().collect();
let window_start = tokens.len().saturating_sub(3);
tokens[window_start..].iter().any(|t| {
let word = t.trim_end_matches(|c: char| c.is_ascii_punctuation());
NEGATORS.contains(&word)
})
}
fn has_non_negated_match(content: &str, pattern: &str) -> bool {
let mut start = 0;
while let Some(rel) = content[start..].find(pattern) {
let pos = start + rel;
if !is_negated(content, pos) {
return true;
}
start = pos + 1;
}
false
}
impl LexiconScorer for ConfigLexiconScorer {
fn score(&self, entry: &ContextEntry, _query: &str) -> f32 {
let content = entry.content.to_lowercase().replace('\'', "");
let mut boost = 0.0_f64;
for (term, weight) in &self.config.terms {
let term_norm = term.to_lowercase().replace('\'', "");
if has_non_negated_match(&content, &term_norm) {
boost += weight;
}
}
for pattern in &self.config.affirmations.patterns {
let pat_norm = pattern.to_lowercase().replace('\'', "");
if has_non_negated_match(&content, &pat_norm) {
boost += 0.5;
}
}
for pattern in &self.config.negations.patterns {
let pat_norm = pattern.to_lowercase().replace('\'', "");
if has_non_negated_match(&content, &pat_norm) {
boost -= 0.3;
}
}
#[allow(
clippy::cast_possible_truncation,
reason = "boost is bounded by clamp; truncation is intentional"
)]
{
boost as f32
}
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::*;
use crate::entry::kind;
fn entry(content: &str) -> ContextEntry {
ContextEntry {
id: "test".into(),
content: content.into(),
timestamp: 0,
kind: kind::MANUAL.to_owned(),
scope: None,
session_id: None,
token_count: None,
metadata: None,
}
}
const SAMPLE_TOML: &str = r#"
[terms]
"Omnissiah" = 1.3
"Astartes" = 1.4
[affirmations]
patterns = ["for the emperor", "it shall be done", "confirmed"]
[negations]
patterns = ["negative", "nay"]
"#;
#[test]
fn from_str_parses_valid_toml() {
let scorer = ConfigLexiconScorer::from_str(SAMPLE_TOML).unwrap();
assert_eq!(scorer.config.terms.len(), 2);
assert_eq!(scorer.config.affirmations.patterns.len(), 3);
assert_eq!(scorer.config.negations.patterns.len(), 2);
}
#[test]
fn from_str_errors_on_malformed_toml() {
let result = ConfigLexiconScorer::from_str("[[[[not valid toml");
assert!(result.is_err());
}
#[test]
fn from_str_rejects_weight_above_max() {
let toml = "[terms]\n\"Heresy\" = 2.0";
let err = ConfigLexiconScorer::from_str(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Heresy"),
"error should name the offending term"
);
assert!(msg.contains('2'), "error should mention the invalid weight");
}
#[test]
fn from_str_rejects_nonpositive_weight() {
let toml = "[terms]\n\"Heresy\" = 0.0";
assert!(ConfigLexiconScorer::from_str(toml).is_err());
}
#[test]
fn from_str_accepts_weight_at_boundary() {
let toml = "[terms]\n\"Emperor\" = 1.5";
assert!(ConfigLexiconScorer::from_str(toml).is_ok());
}
#[test]
fn term_match_is_case_insensitive() {
let scorer = ConfigLexiconScorer::from_str(SAMPLE_TOML).unwrap();
let boost = scorer.score(&entry("the omnissiah guides our path"), "");
assert!(boost > 0.0, "expected positive boost, got {boost}");
}
#[test]
fn no_match_returns_zero() {
let scorer = ConfigLexiconScorer::from_str(SAMPLE_TOML).unwrap();
let boost = scorer.score(&entry("nothing relevant here"), "");
assert!(boost.abs() < f32::EPSILON);
}
#[test]
fn affirmation_adds_boost() {
let scorer = ConfigLexiconScorer::from_str(SAMPLE_TOML).unwrap();
let boost = scorer.score(&entry("for the emperor, we march"), "");
assert!(boost > 0.0);
}
#[test]
fn negation_reduces_boost() {
let scorer = ConfigLexiconScorer::from_str(SAMPLE_TOML).unwrap();
let boost = scorer.score(&entry("negative, we cannot proceed"), "");
assert!(boost < 0.0);
}
#[test]
fn empty_config_scores_zero() {
let scorer = ConfigLexiconScorer::from_str("").unwrap();
let boost = scorer.score(&entry("for the emperor and Astartes"), "");
assert!(boost.abs() < f32::EPSILON);
}
#[test]
fn negation_window_suppresses_negated_affirmation() {
let scorer = ConfigLexiconScorer::from_str(SAMPLE_TOML).unwrap();
let boost = scorer.score(&entry("that is not confirmed"), "");
assert!(
boost.abs() < f32::EPSILON,
"negated affirmation should score zero"
);
}
#[test]
fn unnegated_affirmation_still_boosts() {
let scorer = ConfigLexiconScorer::from_str(SAMPLE_TOML).unwrap();
let boost = scorer.score(&entry("yes that is confirmed"), "");
assert!(boost > 0.0, "un-negated affirmation should boost");
}
#[test]
fn negation_window_does_not_suppress_distant_negator() {
let scorer = ConfigLexiconScorer::from_str(SAMPLE_TOML).unwrap();
let boost = scorer.score(&entry("not sure about many things but confirmed"), "");
assert!(boost > 0.0, "negator outside window should not suppress");
}
#[test]
fn apostrophe_normalization_matches_contraction_variants() {
let scorer = ConfigLexiconScorer::from_str(SAMPLE_TOML).unwrap();
let boost_with = scorer.score(&entry("for the emperor, confirmed"), "");
assert!(boost_with > 0.0);
}
}