use super::nfa::product::{ProductAutomatonChar, ProductStateChar};
use super::nfa::thompson::ThompsonBuilderChar;
use super::nfa::NFAChar;
use super::online_transducer::OnlinePhoneticTransducerChar;
use super::types::RewriteRuleChar;
const MAX_ACTIVE_MATCHES: usize = 1000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScanMatch {
pub byte_range: (usize, usize),
pub char_range: (usize, usize),
pub original_text: String,
pub normalized_text: String,
pub distance: u8,
}
#[derive(Debug, Clone)]
struct PotentialMatch {
start_byte: usize,
start_char: usize,
end_byte: usize,
end_char: usize,
original_chars: Vec<char>,
normalized_chars: Vec<char>,
product_state: ProductStateChar,
min_distance: Option<u8>,
alive: bool,
}
impl PotentialMatch {
fn new(start_byte: usize, start_char: usize, initial_state: ProductStateChar) -> Self {
Self {
start_byte,
start_char,
end_byte: start_byte,
end_char: start_char,
original_chars: Vec::new(),
normalized_chars: Vec::new(),
product_state: initial_state,
min_distance: None,
alive: true,
}
}
fn original_text(&self) -> String {
self.original_chars.iter().collect()
}
fn normalized_text(&self) -> String {
self.normalized_chars.iter().collect()
}
}
#[derive(Debug, Clone)]
pub struct OnlinePhoneticScannerChar {
normalized_query: String,
query_nfa: NFAChar,
product: ProductAutomatonChar,
max_distance: u8,
transducer: OnlinePhoneticTransducerChar,
active_matches: Vec<PotentialMatch>,
completed_matches: Vec<ScanMatch>,
current_byte: usize,
current_char: usize,
pending_original: Vec<(char, usize)>, query_len: usize,
total_matches_found: usize,
}
impl OnlinePhoneticScannerChar {
pub fn new(query: &str, rules: &[RewriteRuleChar], max_distance: u8) -> Self {
let mut query_transducer = OnlinePhoneticTransducerChar::new(rules.to_vec());
let normalized_query = query_transducer.normalize(query);
let builder = ThompsonBuilderChar::new();
let query_nfa = builder.literal(&normalized_query);
let product = ProductAutomatonChar::new(query_nfa.clone(), max_distance);
let transducer = OnlinePhoneticTransducerChar::new(rules.to_vec());
let query_len = normalized_query.chars().count();
Self {
normalized_query,
query_nfa,
product,
max_distance,
transducer,
active_matches: Vec::with_capacity(64),
completed_matches: Vec::new(),
current_byte: 0,
current_char: 0,
pending_original: Vec::new(),
query_len,
total_matches_found: 0,
}
}
pub fn scan(&mut self, document: &str) -> Vec<ScanMatch> {
for c in document.chars() {
self.feed(c, c.len_utf8());
}
self.finish();
self.total_matches_found += self.completed_matches.len();
std::mem::take(&mut self.completed_matches)
}
pub fn feed(&mut self, c: char, byte_len: usize) {
self.pending_original.push((c, byte_len));
let initial_state = self.product.initial_state();
self.active_matches.push(PotentialMatch::new(
self.current_byte,
self.current_char,
initial_state,
));
let normalized_chars: Vec<char> = self.transducer.feed(c).collect();
for norm_c in normalized_chars {
self.process_normalized_char(norm_c);
}
self.current_byte += byte_len;
self.current_char += 1;
self.prune_matches();
}
pub fn finish(&mut self) {
let remaining: Vec<char> = self.transducer.finish().collect();
for norm_c in remaining {
self.process_normalized_char(norm_c);
}
self.try_deletions_at_end();
self.finalize_matches();
}
fn try_deletions_at_end(&mut self) {
let mut updates: Vec<(usize, u8)> = Vec::new();
for (i, m) in self.active_matches.iter().enumerate() {
if !m.alive {
continue;
}
if self.product.is_accepting(&m.product_state) {
let dist = m.product_state.edit_distance();
if m.min_distance.map_or(true, |d| dist < d) {
updates.push((i, dist));
}
continue;
}
let mut state = m.product_state.clone();
let mut attempts = 0;
let max_deletions = self.max_distance.saturating_sub(state.edit_distance());
while attempts < max_deletions as usize + 1 {
if self.product.is_accepting(&state) {
let dist = state.edit_distance();
if m.min_distance.map_or(true, |d| dist < d) {
updates.push((i, dist));
}
break;
}
if let Some(deletion_state) = self.try_deletion_helper(&state) {
state = deletion_state;
attempts += 1;
} else {
break;
}
}
}
for (i, dist) in updates {
if let Some(m) = self.active_matches.get_mut(i) {
if m.min_distance.map_or(true, |d| dist < d) {
m.min_distance = Some(dist);
}
}
}
}
fn try_deletion_helper(&self, state: &ProductStateChar) -> Option<ProductStateChar> {
if state.edit_distance() >= self.max_distance {
return None;
}
use rustc_hash::FxHashSet;
let mut next_states = FxHashSet::default();
for &nfa_state in &state.nfa_states {
for trans in self.query_nfa.transitions_from(nfa_state) {
if trans.label.consumes_input() {
let closure = self
.query_nfa
.epsilon_closure(&std::iter::once(trans.to).collect());
next_states.extend(closure.iter());
}
}
}
if next_states.is_empty() {
None
} else {
Some(ProductStateChar::new(
next_states,
state.accumulated_cost + 1.0,
))
}
}
fn process_normalized_char(&mut self, c: char) {
self.distribute_original_chars();
for m in &mut self.active_matches {
if !m.alive {
continue;
}
m.normalized_chars.push(c);
let successors = self.product.transition(&m.product_state, c);
if successors.is_empty() {
m.alive = false;
} else {
let best = successors
.into_iter()
.min_by_key(|s| s.edit_distance())
.expect("successors not empty");
if self.product.is_accepting(&best) {
let dist = best.edit_distance();
if m.min_distance.map_or(true, |d| dist < d) {
m.min_distance = Some(dist);
}
}
m.product_state = best;
let max_len = self.query_len + self.max_distance as usize + 1;
if m.normalized_chars.len() > max_len * 2 {
m.alive = false;
}
}
}
}
fn distribute_original_chars(&mut self) {
if self.pending_original.is_empty() {
return;
}
let mut byte_pos = self.current_byte;
for &(_, byte_len) in &self.pending_original {
byte_pos = byte_pos.saturating_sub(byte_len);
}
for (orig_c, byte_len) in self.pending_original.drain(..) {
for m in &mut self.active_matches {
if m.alive && m.start_byte <= byte_pos {
m.original_chars.push(orig_c);
m.end_byte = byte_pos + byte_len;
m.end_char = m.start_char + m.original_chars.len();
}
}
byte_pos += byte_len;
}
}
fn prune_matches(&mut self) {
self.active_matches.retain(|m| m.alive);
if self.active_matches.len() > MAX_ACTIVE_MATCHES {
self.active_matches.sort_by(|a, b| {
let a_has = a.min_distance.is_some();
let b_has = b.min_distance.is_some();
if a_has != b_has {
return b_has.cmp(&a_has);
}
match (a.min_distance, b.min_distance) {
(Some(a_d), Some(b_d)) => a_d.cmp(&b_d),
_ => {
a.normalized_chars.len().cmp(&b.normalized_chars.len())
}
}
});
self.active_matches.truncate(MAX_ACTIVE_MATCHES);
}
}
fn finalize_matches(&mut self) {
for m in &self.active_matches {
if let Some(dist) = m.min_distance {
let scan_match = ScanMatch {
byte_range: (m.start_byte, m.end_byte),
char_range: (m.start_char, m.end_char),
original_text: m.original_text(),
normalized_text: m.normalized_text(),
distance: dist,
};
if !self.completed_matches.iter().any(|existing| {
existing.byte_range == scan_match.byte_range
&& existing.distance == scan_match.distance
}) {
self.completed_matches.push(scan_match);
}
}
}
self.completed_matches
.sort_by_key(|m| (m.byte_range.0, m.distance));
}
pub fn normalized_query(&self) -> &str {
&self.normalized_query
}
pub fn stats(&self) -> ScannerStats {
ScannerStats {
chars_scanned: self.current_char,
bytes_scanned: self.current_byte,
matches_found: self.total_matches_found + self.completed_matches.len(),
active_matches: self.active_matches.len(),
}
}
pub fn reset(&mut self) {
self.transducer.reset();
self.active_matches.clear();
self.completed_matches.clear();
self.current_byte = 0;
self.current_char = 0;
self.pending_original.clear();
self.total_matches_found = 0;
}
}
#[derive(Debug, Clone, Copy)]
pub struct ScannerStats {
pub chars_scanned: usize,
pub bytes_scanned: usize,
pub matches_found: usize,
pub active_matches: usize,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::phonetic::types::{ContextChar, PhoneChar};
fn make_rule(pattern: &str, replacement: &str, context: ContextChar) -> RewriteRuleChar {
fn char_to_phone(c: char) -> PhoneChar {
let lower = c.to_ascii_lowercase();
if "aeiou".contains(lower) {
PhoneChar::Vowel(c)
} else {
PhoneChar::Consonant(c)
}
}
RewriteRuleChar {
rule_id: 0,
rule_name: format!("{} -> {}", pattern, replacement),
pattern: pattern.chars().map(char_to_phone).collect(),
replacement: replacement.chars().map(char_to_phone).collect(),
context,
weight: 1.0,
syllable_condition: None,
}
}
#[test]
fn test_empty_document() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let mut scanner = OnlinePhoneticScannerChar::new("phone", &rules, 0);
let matches = scanner.scan("");
assert!(matches.is_empty());
}
#[test]
fn test_exact_match() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let mut scanner = OnlinePhoneticScannerChar::new("phone", &rules, 0);
let matches = scanner.scan("phone");
assert!(!matches.is_empty(), "expected to find 'phone' match");
assert_eq!(matches[0].distance, 0);
}
#[test]
fn test_phonetic_equivalent() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let mut scanner = OnlinePhoneticScannerChar::new("phone", &rules, 0);
assert_eq!(scanner.normalized_query(), "fone");
let matches = scanner.scan("fone");
assert!(!matches.is_empty(), "expected to find 'fone' match");
assert_eq!(matches[0].distance, 0);
assert_eq!(matches[0].original_text, "fone");
}
#[test]
fn test_fude_food_equivalence() {
let rules = vec![
make_rule("oo", "u", ContextChar::Anywhere),
make_rule("e", "", ContextChar::Final),
];
let mut scanner = OnlinePhoneticScannerChar::new("fude", &rules, 0);
assert_eq!(scanner.normalized_query(), "fud");
let matches = scanner.scan("food");
assert!(!matches.is_empty(), "expected 'food' to match 'fude'");
assert_eq!(
matches[0].distance, 0,
"should be exact match after normalization"
);
}
#[test]
fn test_fuzzy_match() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let mut scanner = OnlinePhoneticScannerChar::new("phone", &rules, 1);
let matches = scanner.scan("fon");
assert!(!matches.is_empty(), "expected fuzzy match with distance 1");
assert!(matches[0].distance <= 1);
}
#[test]
fn test_multiple_matches() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let mut scanner = OnlinePhoneticScannerChar::new("phone", &rules, 0);
let matches = scanner.scan("phone and fone are both phones");
assert!(!matches.is_empty(), "expected at least 1 match");
assert_eq!(matches[0].distance, 0);
}
#[test]
fn test_normalized_query() {
let rules = vec![
make_rule("ph", "f", ContextChar::Anywhere),
make_rule("oo", "u", ContextChar::Anywhere),
];
let scanner = OnlinePhoneticScannerChar::new("philosophy", &rules, 0);
assert_eq!(scanner.normalized_query(), "filosofy");
let scanner2 = OnlinePhoneticScannerChar::new("food", &rules, 0);
assert_eq!(scanner2.normalized_query(), "fud");
}
#[test]
fn test_no_match() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let mut scanner = OnlinePhoneticScannerChar::new("phone", &rules, 0);
let matches = scanner.scan("hello world");
assert!(matches.is_empty(), "should not match unrelated text");
}
#[test]
fn test_stats() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let mut scanner = OnlinePhoneticScannerChar::new("phone", &rules, 0);
scanner.scan("phone");
let stats = scanner.stats();
assert_eq!(stats.chars_scanned, 5);
assert_eq!(stats.bytes_scanned, 5);
assert!(stats.matches_found >= 1);
}
#[test]
fn test_reset() {
let rules = vec![make_rule("ph", "f", ContextChar::Anywhere)];
let mut scanner = OnlinePhoneticScannerChar::new("phone", &rules, 0);
scanner.scan("phone");
assert!(scanner.stats().matches_found >= 1);
scanner.reset();
assert_eq!(scanner.stats().chars_scanned, 0);
assert_eq!(scanner.stats().matches_found, 0);
scanner.scan("phone");
assert!(scanner.stats().matches_found >= 1);
}
}