use std::collections::HashMap;
use crate::hmm::HmmModel;
use crate::kcc::{is_khmer, split_kcc};
use crate::normalize::normalize;
use crate::strategy::Strategy;
#[derive(Default)]
struct TrieNode {
children: HashMap<String, TrieNode>,
is_word: bool,
}
#[derive(Default)]
pub struct KhmerTokenizer {
root: TrieNode,
rev_root: TrieNode,
word_count: usize,
strategy: Strategy,
freq_counts: HashMap<String, u64>,
freq_total: u64,
hmm: Option<HmmModel>,
normalization_disabled: bool,
}
impl KhmerTokenizer {
pub fn empty() -> Self {
Self::default()
}
pub fn from_words<I, S>(words: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut t = Self::default();
for w in words {
t.insert(w.as_ref());
}
t
}
pub fn with_strategy(mut self, strategy: Strategy) -> Self {
self.strategy = strategy;
self
}
pub fn with_frequencies<I>(mut self, counts: I) -> Self
where
I: IntoIterator<Item = (String, u64)>,
{
let counts: HashMap<String, u64> = counts.into_iter().collect();
self.freq_total = counts.values().sum();
self.freq_counts = counts;
self
}
pub fn with_hmm(mut self, model: HmmModel) -> Self {
self.hmm = Some(model);
self
}
pub fn without_normalization(mut self) -> Self {
self.normalization_disabled = true;
self
}
pub fn insert(&mut self, word: &str) {
let word = word.trim();
if word.is_empty() {
return;
}
let clusters = split_kcc(word);
let mut node = &mut self.root;
for cl in &clusters {
node = node.children.entry(cl.clone()).or_default();
}
let is_new = !node.is_word;
node.is_word = true;
let mut rnode = &mut self.rev_root;
for cl in clusters.iter().rev() {
rnode = rnode.children.entry(cl.clone()).or_default();
}
rnode.is_word = true;
if is_new {
self.word_count += 1;
}
}
pub fn len(&self) -> usize {
self.word_count
}
pub fn is_empty(&self) -> bool {
self.word_count == 0
}
pub fn contains(&self, word: &str) -> bool {
let mut node = &self.root;
for cl in split_kcc(word) {
match node.children.get(&cl) {
Some(next) => node = next,
None => return false,
}
}
node.is_word
}
pub fn segment(&self, text: &str) -> Vec<String> {
let owned;
let text: &str = if self.normalization_disabled {
text
} else {
owned = normalize(text);
&owned
};
let clusters = split_kcc(text);
let n = clusters.len();
let mut tokens: Vec<String> = Vec::new();
let mut i = 0;
while i < n {
let cl = &clusters[i];
if is_separator(cl) {
i += 1;
continue;
}
let first = cl.chars().next().unwrap();
if !is_khmer(first) {
let start = i;
while i < n
&& !is_separator(&clusters[i])
&& !is_khmer(clusters[i].chars().next().unwrap())
{
i += 1;
}
tokens.push(clusters[start..i].concat());
continue;
}
let start = i;
while i < n && is_khmer(clusters[i].chars().next().unwrap()) {
i += 1;
}
let run = &clusters[start..i];
let run_tokens = match self.strategy {
Strategy::ForwardMaxMatch => forward_match(run, &self.root),
Strategy::BiMaxMatch => bimm(run, &self.root, &self.rev_root),
Strategy::UnigramDp if self.freq_total > 0 => {
unigram_dp(run, &self.root, &self.freq_counts, self.freq_total)
}
Strategy::UnigramDp => forward_match(run, &self.root),
};
let run_tokens = match &self.hmm {
Some(model) => apply_hmm_fallback(run_tokens, &self.root, model),
None => run_tokens,
};
tokens.extend(run_tokens.into_iter().map(|cs| cs.concat()));
}
tokens
}
}
fn is_separator(cl: &str) -> bool {
cl.trim().is_empty() || cl == "\u{200B}"
}
fn greedy_match(clusters: &[String], root: &TrieNode) -> Vec<Vec<String>> {
let n = clusters.len();
let mut tokens = Vec::new();
let mut i = 0;
while i < n {
let mut node = root;
let mut j = i;
let mut last_word_end: Option<usize> = None;
while j < n {
match node.children.get(&clusters[j]) {
Some(next) => {
node = next;
j += 1;
if node.is_word {
last_word_end = Some(j);
}
}
None => break,
}
}
match last_word_end {
Some(end) => {
tokens.push(clusters[i..end].to_vec());
i = end;
}
None => {
tokens.push(vec![clusters[i].clone()]);
i += 1;
}
}
}
tokens
}
fn forward_match(clusters: &[String], root: &TrieNode) -> Vec<Vec<String>> {
greedy_match(clusters, root)
}
fn backward_match(clusters: &[String], rev_root: &TrieNode) -> Vec<Vec<String>> {
let reversed: Vec<String> = clusters.iter().rev().cloned().collect();
let mut tokens = greedy_match(&reversed, rev_root);
tokens.reverse();
for token in &mut tokens {
token.reverse();
}
tokens
}
fn bimm(clusters: &[String], root: &TrieNode, rev_root: &TrieNode) -> Vec<Vec<String>> {
let fwd = forward_match(clusters, root);
let bwd = backward_match(clusters, rev_root);
if fwd.len() != bwd.len() {
return if fwd.len() < bwd.len() { fwd } else { bwd };
}
let singles = |tokens: &[Vec<String>]| tokens.iter().filter(|t| t.len() == 1).count();
if singles(&fwd) <= singles(&bwd) {
fwd
} else {
bwd
}
}
fn unigram_dp(
clusters: &[String],
root: &TrieNode,
freq_counts: &HashMap<String, u64>,
freq_total: u64,
) -> Vec<Vec<String>> {
let n = clusters.len();
if n == 0 {
return Vec::new();
}
let mut dag: Vec<Vec<usize>> = vec![Vec::new(); n];
for (k, edges) in dag.iter_mut().enumerate() {
let mut node = root;
let mut j = k;
while j < n {
match node.children.get(&clusters[j]) {
Some(next) => {
node = next;
j += 1;
if node.is_word {
edges.push(j);
}
}
None => break,
}
}
if edges.is_empty() {
edges.push(k + 1);
}
}
let log_prob = |word: &str| -> f64 {
let count = freq_counts.get(word).copied().unwrap_or(0).max(1) as f64;
(count / freq_total as f64).ln()
};
let mut best_score = vec![f64::NEG_INFINITY; n + 1];
let mut best_end = vec![0usize; n];
best_score[n] = 0.0;
for k in (0..n).rev() {
for &j in &dag[k] {
let word = clusters[k..j].concat();
let score = log_prob(&word) + best_score[j];
if score > best_score[k] {
best_score[k] = score;
best_end[k] = j;
}
}
}
let mut tokens = Vec::new();
let mut k = 0;
while k < n {
let j = best_end[k];
tokens.push(clusters[k..j].to_vec());
k = j;
}
tokens
}
fn is_dict_word(root: &TrieNode, clusters: &[String]) -> bool {
let mut node = root;
for cl in clusters {
match node.children.get(cl) {
Some(next) => node = next,
None => return false,
}
}
node.is_word
}
fn apply_hmm_fallback(
tokens: Vec<Vec<String>>,
root: &TrieNode,
hmm: &HmmModel,
) -> Vec<Vec<String>> {
let mut out = Vec::with_capacity(tokens.len());
let mut buffer: Vec<String> = Vec::new();
for token in tokens {
if token.len() == 1 && !is_dict_word(root, &token) {
buffer.push(token.into_iter().next().unwrap());
continue;
}
if !buffer.is_empty() {
out.extend(hmm.segment_oov(&buffer));
buffer.clear();
}
out.push(token);
}
if !buffer.is_empty() {
out.extend(hmm.segment_oov(&buffer));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_dict_falls_back_to_clusters() {
let tk = KhmerTokenizer::empty();
assert_eq!(tk.segment("ខ្មែរ"), vec!["ខ្មែ", "រ"]);
}
#[test]
fn longest_match_wins() {
let tk = KhmerTokenizer::from_words(["ក", "កម្ពុជា"]);
assert_eq!(tk.segment("កម្ពុជា"), vec!["កម្ពុជា"]);
}
#[test]
fn tracks_word_count() {
let mut tk = KhmerTokenizer::empty();
assert!(tk.is_empty());
tk.insert("ខ្មែរ");
tk.insert("ខ្មែរ"); assert_eq!(tk.len(), 1);
}
#[test]
fn contains_checks_exact_dictionary_entries() {
let tk = KhmerTokenizer::from_words(["កម្ពុជា"]);
assert!(tk.contains("កម្ពុជា"));
assert!(!tk.contains("កម្ពុ")); assert!(!tk.contains("ខ្មែរ")); }
#[test]
fn bimm_matches_forward_when_they_agree() {
let tk = KhmerTokenizer::from_words(["សួស្តី", "អ្នក"]).with_strategy(Strategy::BiMaxMatch);
assert_eq!(tk.segment("សួស្តីអ្នក"), vec!["សួស្តី", "អ្នក"]);
}
#[test]
fn bimm_prefers_fewer_tokens_on_disagreement() {
let tk = KhmerTokenizer::from_words(["អ្នកទាំងអស់គ្នា", "អ្នក", "ទាំងអស់គ្នា"])
.with_strategy(Strategy::BiMaxMatch);
assert_eq!(tk.segment("អ្នកទាំងអស់គ្នា"), vec!["អ្នកទាំងអស់គ្នា"]);
}
#[test]
fn bimm_falls_back_to_forward_on_full_tie() {
let tk = KhmerTokenizer::empty().with_strategy(Strategy::BiMaxMatch);
assert_eq!(tk.segment("ខ្មែរ"), vec!["ខ្មែ", "រ"]);
}
#[test]
fn unigramdp_falls_back_to_forward_without_frequencies() {
let tk = KhmerTokenizer::from_words(["សួស្តី", "អ្នក"]).with_strategy(Strategy::UnigramDp);
assert_eq!(tk.segment("សួស្តីអ្នក"), vec!["សួស្តី", "អ្នក"]);
}
#[test]
fn unigramdp_prefers_the_higher_probability_path_over_greedy_match() {
let tk = KhmerTokenizer::from_words(["ក", "កខ", "ខគ", "គ"]);
assert_eq!(tk.segment("កខគ"), vec!["កខ", "គ"]);
let freqs = [
("ក".to_string(), 100),
("ខគ".to_string(), 100),
("កខ".to_string(), 1),
("គ".to_string(), 1),
];
let tk = tk.with_strategy(Strategy::UnigramDp).with_frequencies(freqs);
assert_eq!(tk.segment("កខគ"), vec!["ក", "ខគ"]);
}
#[test]
fn hmm_fallback_resegments_only_the_truly_oov_run() {
use crate::hmm::HmmModel;
use std::collections::HashMap;
let tk = KhmerTokenizer::from_words(["ក"]);
assert_eq!(tk.segment("កខគង"), vec!["ក", "ខ", "គ", "ង"]);
let start = [50, 0, 0, 0]; let mut trans = [[0u64; 4]; 4];
trans[0][2] = 50; trans[2][3] = 50; let mut emit = HashMap::new();
emit.insert("ខ".to_string(), [50, 0, 0, 0]); emit.insert("គ".to_string(), [0, 0, 50, 0]); emit.insert("ង".to_string(), [0, 0, 0, 50]); let model = HmmModel::from_counts(start, trans, emit);
let tk = tk.with_hmm(model);
assert_eq!(tk.segment("កខគង"), vec!["ក", "ខគ", "ង"]);
}
#[test]
fn zwsp_separates_tokens_without_producing_one() {
let tk = KhmerTokenizer::from_words(["សួស្តី", "អ្នក"]);
assert_eq!(tk.segment("សួស្តី\u{200B}អ្នក"), vec!["សួស្តី", "អ្នក"]);
}
#[test]
fn zwsp_is_trusted_as_a_hard_word_boundary() {
let tk = KhmerTokenizer::from_words(["កខ"]);
assert_eq!(tk.segment("កខ"), vec!["កខ"]); assert_eq!(tk.segment("ក\u{200B}ខ"), vec!["ក", "ខ"]);
}
#[test]
fn zwsp_splits_non_khmer_runs_too() {
let tk = KhmerTokenizer::empty();
assert_eq!(tk.segment("Hello\u{200B}World"), vec!["Hello", "World"]);
}
#[test]
fn normalization_lets_a_malformed_spelling_match_the_canonical_dictionary_entry() {
let tk = KhmerTokenizer::from_words(["សិទ្ធិ"]);
assert_eq!(tk.segment("សិទិ្ធ"), vec!["សិទ្ធិ"]);
let tk = tk.without_normalization();
assert_ne!(tk.segment("សិទិ្ធ"), vec!["សិទ្ធិ"]);
}
}