use crate::segmentation::pos_tagger::tag_pos;
use crate::types::TokenSpan;
use crate::unicode_utils::{normalize_nfc, CharOffsetMap};
use daachorse::DoubleArrayAhoCorasick;
use std::sync::LazyLock;
const EMBEDDED_LEXICON_DATA: &str = include_str!("../../data/lexicon_dictionary.txt");
pub static EMBEDDED_DICTIONARY: LazyLock<DoubleArrayAhoCorasick<usize>> = LazyLock::new(|| {
let mut words: Vec<String> = EMBEDDED_LEXICON_DATA
.lines()
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect();
words.sort();
words.dedup();
DoubleArrayAhoCorasick::new(words).expect("Khởi tạo DoubleArrayAhoCorasick nhúng thất bại")
});
pub struct DaachorseSegmenter {
pma: Option<DoubleArrayAhoCorasick<usize>>,
}
impl DaachorseSegmenter {
pub fn new() -> Self {
Self { pma: None }
}
pub fn with_vocab(custom_vocab: &[&str]) -> Result<Self, String> {
let mut patterns: Vec<String> = custom_vocab
.iter()
.map(|w| normalize_nfc(w).to_lowercase())
.collect();
patterns.sort();
patterns.dedup();
let pma = DoubleArrayAhoCorasick::new(patterns)
.map_err(|e| format!("Lỗi khởi tạo Daachorse: {:?}", e))?;
Ok(Self { pma: Some(pma) })
}
pub fn tokenize(&self, text: &str) -> Vec<TokenSpan> {
let norm = normalize_nfc(text);
let lower = norm.to_lowercase();
let mapper = CharOffsetMap::new(&norm);
let pma = self.pma.as_ref().unwrap_or(&EMBEDDED_DICTIONARY);
let mut raw_matches: Vec<(usize, usize)> = pma
.find_overlapping_iter(&lower)
.map(|m| (m.start(), m.end()))
.collect();
raw_matches.retain(|&(start, end)| {
Self::is_boundary(&lower, start, true) && Self::is_boundary(&lower, end, false)
});
raw_matches.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| (b.1 - b.0).cmp(&(a.1 - a.0))));
let mut non_overlapping_compounds: Vec<(usize, usize)> = Vec::new();
let mut occupied_until = 0;
for (start, end) in raw_matches {
if start >= occupied_until {
non_overlapping_compounds.push((start, end));
occupied_until = end;
}
}
let mut tokens: Vec<TokenSpan> = Vec::new();
let mut cursor = 0;
for (comp_start, comp_end) in non_overlapping_compounds {
if cursor < comp_start {
Self::tokenize_simple_range(
&norm,
cursor,
comp_start,
&mapper,
&mut tokens,
);
}
let (start_char, end_char) = mapper.convert_range(comp_start, comp_end);
let matched_text = norm[comp_start..comp_end].to_string();
let is_compound = matched_text.contains(' ');
let pos = Some(tag_pos(&matched_text));
tokens.push(TokenSpan {
text: matched_text,
start: start_char,
end: end_char,
is_compound,
pos,
});
cursor = comp_end;
}
if cursor < norm.len() {
Self::tokenize_simple_range(
&norm,
cursor,
norm.len(),
&mapper,
&mut tokens,
);
}
tokens
}
#[inline]
fn is_boundary(text: &str, byte_idx: usize, is_start: bool) -> bool {
if is_start {
if byte_idx == 0 {
return true;
}
let prev_char = text[..byte_idx].chars().next_back().unwrap();
!prev_char.is_alphanumeric() && prev_char != '_' && prev_char != '-'
} else {
if byte_idx >= text.len() {
return true;
}
let next_char = text[byte_idx..].chars().next().unwrap();
!next_char.is_alphanumeric() && next_char != '_' && next_char != '-'
}
}
fn tokenize_simple_range(
norm: &str,
start_byte: usize,
end_byte: usize,
mapper: &CharOffsetMap,
tokens: &mut Vec<TokenSpan>,
) {
let slice = &norm[start_byte..end_byte];
let mut char_iter = slice.char_indices().peekable();
while let Some((rel_start, ch)) = char_iter.next() {
if ch.is_whitespace() {
continue;
}
let tok_start_byte = start_byte + rel_start;
if ch.is_alphanumeric() || ch == '_' || ch == '-' {
let mut tok_end_byte = tok_start_byte + ch.len_utf8();
while let Some(&(next_rel, next_ch)) = char_iter.peek() {
if next_ch.is_alphanumeric() || next_ch == '_' || next_ch == '-' {
tok_end_byte = start_byte + next_rel + next_ch.len_utf8();
char_iter.next();
} else {
break;
}
}
let (start_char, end_char) = mapper.convert_range(tok_start_byte, tok_end_byte);
let text = norm[tok_start_byte..tok_end_byte].to_string();
let pos = Some(tag_pos(&text));
tokens.push(TokenSpan {
text,
start: start_char,
end: end_char,
is_compound: false,
pos,
});
} else {
let tok_end_byte = tok_start_byte + ch.len_utf8();
let (start_char, end_char) = mapper.convert_range(tok_start_byte, tok_end_byte);
let text = norm[tok_start_byte..tok_end_byte].to_string();
let pos = Some(tag_pos(&text));
tokens.push(TokenSpan {
text,
start: start_char,
end: end_char,
is_compound: false,
pos,
});
}
}
}
}
impl Default for DaachorseSegmenter {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_embedded_dictionary_loading() {
let segmenter = DaachorseSegmenter::new();
let text = "Học sinh trong căn phòng thí nghiệm.";
let tokens = segmenter.tokenize(text);
let compounds: Vec<String> = tokens
.iter()
.filter(|t| t.is_compound)
.map(|t| t.text.to_lowercase())
.collect();
assert!(compounds.iter().any(|s| s == "học sinh"));
assert!(compounds.iter().any(|s| s == "căn phòng"));
assert!(compounds.iter().any(|s| s == "thí nghiệm"));
}
#[test]
fn test_overlap_disambiguation() {
let segmenter = DaachorseSegmenter::new();
let text = "học sinh học";
let tokens = segmenter.tokenize(text);
assert_eq!(tokens.len(), 2);
assert_eq!(tokens[0].text, "học sinh");
assert!(tokens[0].is_compound);
assert_eq!(tokens[1].text, "học");
assert!(!tokens[1].is_compound);
let text2 = "nghiên cứu sinh học";
let tokens2 = segmenter.tokenize(text2);
assert_eq!(tokens2.len(), 2);
assert_eq!(tokens2[0].text, "nghiên cứu sinh");
assert!(tokens2[0].is_compound);
assert_eq!(tokens2[1].text, "học");
assert!(!tokens2[1].is_compound);
let text3 = "học phần sinh học";
let tokens3 = segmenter.tokenize(text3);
let compounds3: Vec<String> = tokens3
.iter()
.filter(|t| t.is_compound)
.map(|t| t.text.to_lowercase())
.collect();
assert!(compounds3.iter().any(|s| s == "học phần"));
assert!(compounds3.iter().any(|s| s == "sinh học"));
}
#[test]
fn test_longest_match_priority() {
let segmenter = DaachorseSegmenter::new();
let text = "Mẹ mua một chiếc bàn là mới.";
let tokens = segmenter.tokenize(text);
let compounds: Vec<&str> = tokens
.iter()
.filter(|t| t.is_compound)
.map(|t| t.text.as_str())
.collect();
assert!(compounds.contains(&"bàn là"));
}
#[test]
fn test_char_span_integrity_with_62k_words() {
let segmenter = DaachorseSegmenter::new();
let text = "Trí tuệ nhân tạo và chuyển đổi số đang tối ưu hóa hệ thống phân tán.";
let tokens = segmenter.tokenize(text);
let text_chars: Vec<char> = normalize_nfc(text).chars().collect();
for tok in &tokens {
let extracted: String = text_chars[tok.start..tok.end].iter().collect();
assert_eq!(tok.text, extracted, "Lệch tọa độ token: {:?}", tok);
}
}
}