use std::collections::{HashMap, HashSet, VecDeque};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum SynonymRelation {
Exact,
Near,
Broader,
Narrower,
}
#[derive(Clone, Debug)]
pub struct SynonymEdge {
pub target: String,
pub relation: SynonymRelation,
pub weight: f32,
}
#[derive(Clone, Debug)]
pub struct ExpanderConfig {
pub max_hops: usize,
pub min_weight: f32,
pub max_expansions: usize,
}
impl Default for ExpanderConfig {
fn default() -> Self {
Self {
max_hops: 2,
min_weight: 0.5,
max_expansions: 20,
}
}
}
#[derive(Clone, Debug)]
pub struct ExpandedTerm {
pub term: String,
pub relation: SynonymRelation,
pub cumulative_weight: f32,
pub hops: usize,
}
#[derive(Clone, Debug, Default)]
pub struct SynonymExpanderStats {
pub total_terms: usize,
pub total_edges: usize,
pub total_expand_calls: u64,
pub total_terms_expanded: u64,
}
struct FrontierEntry {
term: String,
hops: usize,
cumulative_weight: f32,
relation: SynonymRelation,
}
pub struct SemanticSynonymExpander {
pub graph: HashMap<String, Vec<SynonymEdge>>,
pub config: ExpanderConfig,
pub stats: SynonymExpanderStats,
}
impl SemanticSynonymExpander {
pub fn new(config: ExpanderConfig) -> Self {
Self {
graph: HashMap::new(),
config,
stats: SynonymExpanderStats::default(),
}
}
pub fn add_synonym(&mut self, from: &str, to: &str, relation: SynonymRelation, weight: f32) {
let reverse_relation = match relation {
SynonymRelation::Broader => SynonymRelation::Narrower,
SynonymRelation::Narrower => SynonymRelation::Broader,
other => other,
};
let forward_added = Self::insert_edge(
&mut self.graph,
from,
SynonymEdge {
target: to.to_owned(),
relation,
weight,
},
);
let reverse_added = Self::insert_edge(
&mut self.graph,
to,
SynonymEdge {
target: from.to_owned(),
relation: reverse_relation,
weight,
},
);
self.stats.total_edges += forward_added as usize + reverse_added as usize;
self.stats.total_terms = self.graph.len();
}
fn insert_edge(
graph: &mut HashMap<String, Vec<SynonymEdge>>,
node: &str,
edge: SynonymEdge,
) -> bool {
let edges = graph.entry(node.to_owned()).or_default();
let is_dup = edges
.iter()
.any(|e| e.target == edge.target && e.relation == edge.relation);
if is_dup {
return false;
}
edges.push(edge);
true
}
pub fn expand(
&mut self,
term: &str,
allowed_relations: Option<&[SynonymRelation]>,
) -> Vec<ExpandedTerm> {
self.stats.total_expand_calls += 1;
if !self.graph.contains_key(term) {
return Vec::new();
}
let mut visited: HashSet<String> = HashSet::new();
visited.insert(term.to_owned());
let mut queue: VecDeque<FrontierEntry> = VecDeque::new();
if let Some(edges) = self.graph.get(term) {
for edge in edges {
if edge.weight < self.config.min_weight {
continue;
}
if let Some(allowed) = allowed_relations {
if !allowed.contains(&edge.relation) {
continue;
}
}
if visited.insert(edge.target.clone()) {
queue.push_back(FrontierEntry {
term: edge.target.clone(),
hops: 1,
cumulative_weight: edge.weight,
relation: edge.relation,
});
}
}
}
let mut results: Vec<ExpandedTerm> = Vec::new();
while let Some(entry) = queue.pop_front() {
results.push(ExpandedTerm {
term: entry.term.clone(),
relation: entry.relation,
cumulative_weight: entry.cumulative_weight,
hops: entry.hops,
});
if entry.hops >= self.config.max_hops {
continue;
}
if let Some(edges) = self.graph.get(&entry.term) {
let candidates: Vec<_> = edges
.iter()
.filter(|e| {
e.weight >= self.config.min_weight
&& allowed_relations
.map(|a| a.contains(&e.relation))
.unwrap_or(true)
&& !visited.contains(&e.target)
})
.map(|e| (e.target.clone(), e.relation, e.weight))
.collect();
for (target, relation, weight) in candidates {
if visited.insert(target.clone()) {
queue.push_back(FrontierEntry {
term: target,
hops: entry.hops + 1,
cumulative_weight: entry.cumulative_weight * weight,
relation,
});
}
}
}
}
results.sort_by(|a, b| {
b.cumulative_weight
.partial_cmp(&a.cumulative_weight)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.term.cmp(&b.term))
});
results.truncate(self.config.max_expansions);
self.stats.total_terms_expanded += results.len() as u64;
results
}
pub fn remove_term(&mut self, term: &str) {
self.graph.remove(term);
for edges in self.graph.values_mut() {
edges.retain(|e| e.target != term);
}
self.stats.total_terms = self.graph.len();
self.stats.total_edges = self.graph.values().map(|v| v.len()).sum();
}
pub fn stats(&self) -> &SynonymExpanderStats {
&self.stats
}
pub fn term_count(&self) -> usize {
self.graph.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_expander() -> SemanticSynonymExpander {
SemanticSynonymExpander::new(ExpanderConfig::default())
}
#[test]
fn test_add_synonym_bidirectional_exact() {
let mut exp = default_expander();
exp.add_synonym("car", "automobile", SynonymRelation::Exact, 0.9);
assert!(exp.graph["car"].iter().any(|e| e.target == "automobile"));
assert!(exp.graph["automobile"].iter().any(|e| e.target == "car"));
}
#[test]
fn test_add_synonym_bidirectional_near() {
let mut exp = default_expander();
exp.add_synonym("happy", "joyful", SynonymRelation::Near, 0.8);
let fwd = exp.graph["happy"]
.iter()
.find(|e| e.target == "joyful")
.expect("forward edge missing");
let rev = exp.graph["joyful"]
.iter()
.find(|e| e.target == "happy")
.expect("reverse edge missing");
assert_eq!(fwd.relation, SynonymRelation::Near);
assert_eq!(rev.relation, SynonymRelation::Near);
}
#[test]
fn test_add_synonym_broader_reverse_is_narrower() {
let mut exp = default_expander();
exp.add_synonym("spaniel", "dog", SynonymRelation::Broader, 0.9);
let rev = exp.graph["dog"]
.iter()
.find(|e| e.target == "spaniel")
.expect("reverse edge missing");
assert_eq!(rev.relation, SynonymRelation::Narrower);
}
#[test]
fn test_add_synonym_narrower_reverse_is_broader() {
let mut exp = default_expander();
exp.add_synonym("dog", "spaniel", SynonymRelation::Narrower, 0.9);
let rev = exp.graph["spaniel"]
.iter()
.find(|e| e.target == "dog")
.expect("reverse edge missing");
assert_eq!(rev.relation, SynonymRelation::Broader);
}
#[test]
fn test_add_synonym_weight_preserved() {
let mut exp = default_expander();
exp.add_synonym("a", "b", SynonymRelation::Exact, 0.75);
assert!((exp.graph["a"][0].weight - 0.75).abs() < f32::EPSILON);
assert!((exp.graph["b"][0].weight - 0.75).abs() < f32::EPSILON);
}
#[test]
fn test_add_synonym_no_duplicate_edges() {
let mut exp = default_expander();
exp.add_synonym("car", "automobile", SynonymRelation::Exact, 0.9);
exp.add_synonym("car", "automobile", SynonymRelation::Exact, 0.7);
assert_eq!(exp.graph["car"].len(), 1);
assert_eq!(exp.graph["automobile"].len(), 1);
}
#[test]
fn test_add_synonym_duplicate_does_not_increment_stats() {
let mut exp = default_expander();
exp.add_synonym("car", "automobile", SynonymRelation::Exact, 0.9);
let edges_after_first = exp.stats.total_edges;
exp.add_synonym("car", "automobile", SynonymRelation::Exact, 0.7);
assert_eq!(exp.stats.total_edges, edges_after_first);
}
#[test]
fn test_add_synonym_different_relation_is_not_duplicate() {
let mut exp = default_expander();
exp.add_synonym("word", "synonym", SynonymRelation::Exact, 0.9);
exp.add_synonym("word", "synonym", SynonymRelation::Near, 0.7);
assert_eq!(exp.graph["word"].len(), 2);
}
#[test]
fn test_stats_total_terms_and_edges() {
let mut exp = default_expander();
exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
assert_eq!(exp.stats.total_terms, 2);
assert_eq!(exp.stats.total_edges, 2); }
#[test]
fn test_stats_multiple_synonyms() {
let mut exp = default_expander();
exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
exp.add_synonym("a", "c", SynonymRelation::Near, 0.8);
assert_eq!(exp.stats.total_terms, 3);
assert_eq!(exp.stats.total_edges, 4); }
#[test]
fn test_expand_empty_for_unknown_term() {
let mut exp = default_expander();
let results = exp.expand("ghost", None);
assert!(results.is_empty());
}
#[test]
fn test_expand_single_hop() {
let mut exp = default_expander();
exp.add_synonym("car", "automobile", SynonymRelation::Exact, 0.9);
let results = exp.expand("car", None);
assert_eq!(results.len(), 1);
assert_eq!(results[0].term, "automobile");
assert_eq!(results[0].hops, 1);
assert!((results[0].cumulative_weight - 0.9).abs() < 1e-5);
}
#[test]
fn test_expand_does_not_include_query_term() {
let mut exp = default_expander();
exp.add_synonym("car", "automobile", SynonymRelation::Exact, 0.9);
let results = exp.expand("car", None);
assert!(!results.iter().any(|r| r.term == "car"));
}
#[test]
fn test_expand_bfs_up_to_max_hops() {
let config = ExpanderConfig {
max_hops: 2,
..ExpanderConfig::default()
};
let mut exp = SemanticSynonymExpander::new(config);
exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
exp.add_synonym("b", "c", SynonymRelation::Exact, 0.9);
exp.add_synonym("c", "d", SynonymRelation::Exact, 0.9);
let results = exp.expand("a", None);
let terms: Vec<&str> = results.iter().map(|r| r.term.as_str()).collect();
assert!(terms.contains(&"b"), "hop-1 term b must be present");
assert!(terms.contains(&"c"), "hop-2 term c must be present");
assert!(
!terms.contains(&"d"),
"hop-3 term d must be absent with max_hops=2"
);
}
#[test]
fn test_expand_max_hops_one() {
let config = ExpanderConfig {
max_hops: 1,
..ExpanderConfig::default()
};
let mut exp = SemanticSynonymExpander::new(config);
exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
exp.add_synonym("b", "c", SynonymRelation::Exact, 0.9);
let results = exp.expand("a", None);
let terms: Vec<&str> = results.iter().map(|r| r.term.as_str()).collect();
assert!(terms.contains(&"b"));
assert!(!terms.contains(&"c"));
}
#[test]
fn test_expand_filters_by_min_weight() {
let config = ExpanderConfig {
min_weight: 0.7,
..ExpanderConfig::default()
};
let mut exp = SemanticSynonymExpander::new(config);
exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9); exp.add_synonym("a", "c", SynonymRelation::Near, 0.4); let results = exp.expand("a", None);
let terms: Vec<&str> = results.iter().map(|r| r.term.as_str()).collect();
assert!(terms.contains(&"b"));
assert!(!terms.contains(&"c"));
}
#[test]
fn test_expand_min_weight_exact_boundary() {
let config = ExpanderConfig {
min_weight: 0.5,
..ExpanderConfig::default()
};
let mut exp = SemanticSynonymExpander::new(config);
exp.add_synonym("x", "y", SynonymRelation::Exact, 0.5); let results = exp.expand("x", None);
assert!(
!results.is_empty(),
"edge at exactly min_weight should be followed"
);
}
#[test]
fn test_expand_filters_by_allowed_relations() {
let mut exp = default_expander();
exp.add_synonym("fruit", "apple", SynonymRelation::Narrower, 0.9);
exp.add_synonym("fruit", "food", SynonymRelation::Broader, 0.9);
let results = exp.expand("fruit", Some(&[SynonymRelation::Narrower]));
let terms: Vec<&str> = results.iter().map(|r| r.term.as_str()).collect();
assert!(terms.contains(&"apple"));
assert!(!terms.contains(&"food"));
}
#[test]
fn test_expand_allowed_relations_none_means_all() {
let mut exp = default_expander();
exp.add_synonym("fruit", "apple", SynonymRelation::Narrower, 0.9);
exp.add_synonym("fruit", "food", SynonymRelation::Broader, 0.9);
let results = exp.expand("fruit", None);
assert!(results.len() >= 2);
}
#[test]
fn test_expand_cumulative_weight_product() {
let config = ExpanderConfig {
max_hops: 3,
..ExpanderConfig::default()
};
let mut exp = SemanticSynonymExpander::new(config);
exp.add_synonym("a", "b", SynonymRelation::Exact, 0.8);
exp.add_synonym("b", "c", SynonymRelation::Exact, 0.9);
let results = exp.expand("a", None);
let c = results
.iter()
.find(|r| r.term == "c")
.expect("c should be found");
let expected = 0.8_f32 * 0.9;
assert!(
(c.cumulative_weight - expected).abs() < 1e-5,
"expected cumulative_weight ~ {expected}, got {}",
c.cumulative_weight
);
assert_eq!(c.hops, 2);
}
#[test]
fn test_expand_truncates_at_max_expansions() {
let config = ExpanderConfig {
max_expansions: 3,
..ExpanderConfig::default()
};
let mut exp = SemanticSynonymExpander::new(config);
for i in 0..10 {
exp.add_synonym("root", &format!("term{i}"), SynonymRelation::Near, 0.9);
}
let results = exp.expand("root", None);
assert!(results.len() <= 3, "results exceeded max_expansions");
}
#[test]
fn test_expand_sorted_by_weight_descending() {
let mut exp = default_expander();
exp.add_synonym("root", "high", SynonymRelation::Exact, 0.95);
exp.add_synonym("root", "low", SynonymRelation::Near, 0.6);
let results = exp.expand("root", None);
assert_eq!(results[0].term, "high");
}
#[test]
fn test_expand_sorted_alphabetically_on_weight_tie() {
let mut exp = default_expander();
exp.add_synonym("root", "beta", SynonymRelation::Exact, 0.8);
exp.add_synonym("root", "alpha", SynonymRelation::Exact, 0.8);
let results = exp.expand("root", None);
assert_eq!(results[0].term, "alpha");
assert_eq!(results[1].term, "beta");
}
#[test]
fn test_expand_increments_total_expand_calls() {
let mut exp = default_expander();
exp.expand("unknown", None);
exp.expand("unknown", None);
assert_eq!(exp.stats.total_expand_calls, 2);
}
#[test]
fn test_expand_accumulates_total_terms_expanded() {
let mut exp = default_expander();
exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
exp.add_synonym("a", "c", SynonymRelation::Exact, 0.9);
let r = exp.expand("a", None);
assert_eq!(exp.stats.total_terms_expanded, r.len() as u64);
exp.expand("a", None);
assert_eq!(exp.stats.total_terms_expanded, 2 * r.len() as u64);
}
#[test]
fn test_remove_term_removes_node() {
let mut exp = default_expander();
exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
exp.remove_term("b");
assert!(!exp.graph.contains_key("b"));
}
#[test]
fn test_remove_term_removes_incoming_edges() {
let mut exp = default_expander();
exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
exp.remove_term("b");
assert!(!exp.graph["a"].iter().any(|e| e.target == "b"));
}
#[test]
fn test_remove_term_updates_stats() {
let mut exp = default_expander();
exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
exp.remove_term("b");
assert_eq!(exp.stats.total_terms, 1); assert_eq!(exp.stats.total_edges, 0); }
#[test]
fn test_remove_term_unknown_is_noop() {
let mut exp = default_expander();
exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
let terms_before = exp.stats.total_terms;
let edges_before = exp.stats.total_edges;
exp.remove_term("ghost");
assert_eq!(exp.stats.total_terms, terms_before);
assert_eq!(exp.stats.total_edges, edges_before);
}
#[test]
fn test_term_count() {
let mut exp = default_expander();
assert_eq!(exp.term_count(), 0);
exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
assert_eq!(exp.term_count(), 2);
}
#[test]
fn test_stats_accessor_returns_ref() {
let exp = default_expander();
let s = exp.stats();
assert_eq!(s.total_expand_calls, 0);
}
#[test]
fn test_expander_config_defaults() {
let cfg = ExpanderConfig::default();
assert_eq!(cfg.max_hops, 2);
assert!((cfg.min_weight - 0.5).abs() < f32::EPSILON);
assert_eq!(cfg.max_expansions, 20);
}
#[test]
fn test_expand_no_revisit_cycle() {
let mut exp = default_expander();
exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
exp.add_synonym("b", "c", SynonymRelation::Exact, 0.9);
exp.add_synonym("c", "a", SynonymRelation::Exact, 0.9);
let results = exp.expand("a", None);
assert!(!results.iter().any(|r| r.term == "a"));
}
}