263 lines
7.9 KiB
Rust
263 lines
7.9 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use super::word::Word;
|
|
|
|
#[derive(Clone)]
|
|
pub struct WordIndex {
|
|
by_length: HashMap<usize, Vec<usize>>,
|
|
letter_idx: HashMap<(usize, usize, u8), Vec<u64>>,
|
|
chunk_counts: HashMap<usize, usize>,
|
|
allowed: HashMap<(usize, usize), u32>,
|
|
}
|
|
|
|
impl WordIndex {
|
|
pub fn build(words: &[Word]) -> Self {
|
|
let mut by_length: HashMap<usize, Vec<usize>> = 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<u64>> = HashMap::new();
|
|
let mut chunk_counts: HashMap<usize, usize> = 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<usize> {
|
|
self.chunk_counts.get(&len).copied()
|
|
}
|
|
|
|
pub fn get_letter_bits(&self, len: usize, pos: usize, letter: u8) -> Option<&Vec<u64>> {
|
|
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<usize> {
|
|
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<Word> {
|
|
["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());
|
|
}
|
|
}
|