use std::collections::HashMap; use super::word::Word; #[derive(Clone)] pub struct WordIndex { by_length: HashMap>, letter_idx: HashMap<(usize, usize, u8), Vec>, chunk_counts: HashMap, allowed: HashMap<(usize, usize), u32>, } impl WordIndex { pub fn build(words: &[Word]) -> Self { let mut by_length: HashMap> = HashMap::new(); for (i, w) in words.iter().enumerate() { by_length.entry(w.length).or_default().push(i); } let mut letter_idx: HashMap<(usize, usize, u8), Vec> = HashMap::new(); let mut chunk_counts: HashMap = HashMap::new(); for (&len, indices) in &by_length { let n = indices.len(); let chunks = (n + 63) / 64; chunk_counts.insert(len, chunks); for (bit_pos, &word_idx) in indices.iter().enumerate() { let word = &words[word_idx]; let chunk = bit_pos / 64; let bit = bit_pos % 64; for pos in 0..word.length { let letter = word.char_at(pos); let key = (len, pos, letter); let entry = letter_idx.entry(key).or_insert_with(|| vec![0u64; chunks]); entry[chunk] |= 1u64 << bit; } } } let mut allowed: HashMap<(usize, usize), u32> = HashMap::new(); for (&len, indices) in &by_length { for pos in 0..len { let mut mask = 0u32; for &wi in indices { let letter = words[wi].char_at(pos); mask |= 1u32 << (letter - b'A'); } allowed.insert((len, pos), mask); } } Self { by_length, letter_idx, chunk_counts, allowed, } } pub fn allowed_letters(&self, len: usize, pos: usize) -> u32 { self.allowed.get(&(len, pos)).copied().unwrap_or(0) } pub fn chunk_count(&self, len: usize) -> Option { self.chunk_counts.get(&len).copied() } pub fn get_letter_bits(&self, len: usize, pos: usize, letter: u8) -> Option<&Vec> { self.letter_idx.get(&(len, pos, letter)) } pub fn words_of_length(&self, len: usize) -> &[usize] { self.by_length.get(&len).map(|v| v.as_slice()).unwrap_or(&[]) } pub fn count_of_length(&self, len: usize) -> usize { self.by_length.get(&len).map(|v| v.len()).unwrap_or(0) } /// Get candidates matching a pattern constraint. /// `constraints` is a list of (position, letter) pairs that must match. /// Returns indices into the master word list. pub fn candidates(&self, len: usize, constraints: &[(usize, u8)]) -> Vec { let indices = match self.by_length.get(&len) { Some(v) => v, None => return Vec::new(), }; let chunks = match self.chunk_counts.get(&len) { Some(&c) => c, None => return Vec::new(), }; if constraints.is_empty() { return indices.clone(); } // intersect bitsets for all constraints let mut result = vec![u64::MAX; chunks]; // mask off unused bits in last chunk let n = indices.len(); let last_bits = n % 64; if last_bits > 0 { result[chunks - 1] = (1u64 << last_bits) - 1; } for &(pos, letter) in constraints { let key = (len, pos, letter); match self.letter_idx.get(&key) { Some(bits) => { for (i, chunk) in result.iter_mut().enumerate() { *chunk &= bits[i]; } } None => return Vec::new(), // no words have this letter at this position } } // extract set bits as word indices let mut out = Vec::new(); for (chunk_i, &bits) in result.iter().enumerate() { let mut b = bits; while b != 0 { let bit = b.trailing_zeros() as usize; out.push(indices[chunk_i * 64 + bit]); b &= b - 1; } } out } /// Count matching candidates using popcount - no list allocation. pub fn candidate_count(&self, len: usize, constraints: &[(usize, u8)]) -> usize { let indices = match self.by_length.get(&len) { Some(v) => v, None => return 0, }; let chunks = match self.chunk_counts.get(&len) { Some(&c) => c, None => return 0, }; if constraints.is_empty() { return indices.len(); } let n = indices.len(); let mut count = 0usize; for chunk_i in 0..chunks { let mut bits = if chunk_i == chunks - 1 && n % 64 != 0 { (1u64 << (n % 64)) - 1 } else { u64::MAX }; for &(pos, letter) in constraints { let key = (len, pos, letter); match self.letter_idx.get(&key) { Some(b) => bits &= b[chunk_i], None => return 0, } } count += bits.count_ones() as usize; } count } /// Check if at least one candidate exists - early exit. pub fn has_any_candidate(&self, len: usize, constraints: &[(usize, u8)]) -> bool { let indices = match self.by_length.get(&len) { Some(v) => v, None => return false, }; let chunks = match self.chunk_counts.get(&len) { Some(&c) => c, None => return false, }; if constraints.is_empty() { return !indices.is_empty(); } let n = indices.len(); for chunk_i in 0..chunks { let mut bits = if chunk_i == chunks - 1 && n % 64 != 0 { (1u64 << (n % 64)) - 1 } else { u64::MAX }; for &(pos, letter) in constraints { let key = (len, pos, letter); match self.letter_idx.get(&key) { Some(b) => bits &= b[chunk_i], None => { bits = 0; break; } } } if bits != 0 { return true; } } false } } #[cfg(test)] mod tests { use super::*; fn test_words() -> Vec { ["CAT", "CAR", "COP", "DOG", "DIG", "BAT", "BAR", "BIG"] .iter() .map(|&w| Word::new(w.to_string(), 0.5)) .collect() } #[test] fn candidates_no_constraints() { let words = test_words(); let idx = WordIndex::build(&words); let c = idx.candidates(3, &[]); assert_eq!(c.len(), 8); } #[test] fn candidates_single_constraint() { let words = test_words(); let idx = WordIndex::build(&words); // words starting with 'C': CAT, CAR, COP let c = idx.candidates(3, &[(0, b'C')]); assert_eq!(c.len(), 3); for &i in &c { assert!(words[i].text.starts_with('C')); } } #[test] fn candidates_multiple_constraints() { let words = test_words(); let idx = WordIndex::build(&words); // words starting with 'C' and ending with 'T': CAT let c = idx.candidates(3, &[(0, b'C'), (2, b'T')]); assert_eq!(c.len(), 1); assert_eq!(words[c[0]].text, "CAT"); } #[test] fn candidates_no_match() { let words = test_words(); let idx = WordIndex::build(&words); let c = idx.candidates(3, &[(0, b'Z')]); assert!(c.is_empty()); } #[test] fn candidates_wrong_length() { let words = test_words(); let idx = WordIndex::build(&words); let c = idx.candidates(5, &[]); assert!(c.is_empty()); } }