use std::collections::{HashMap, HashSet};
use std::sync::OnceLock;
use super::syllables::{intact_pinyin_list, is_intact, MAX_SYLLABLE_LENGTH};
const MAX_SAMPLES: usize = 128;
const SAMPLE_STEP: f32 = 0.25;
const ALIGN_SCALE: f32 = 0.3;
const LINE_SCALE: f32 = 0.25;
const LETTER_COST: f32 = 0.8;
const REPEAT_COST: f32 = 0.8;
const DICTIONARY_WEIGHT: f32 = 0.7;
const UNKNOWN_WORD_COST: f32 = 6.0;
const WORD_SYLLABLE_BONUS: f32 = 1.5;
const BACKTRACK_COST: f32 = 2.0;
const SKIPPED_DWELL_COST: f32 = 4.0;
const NEAR_DISTANCE: f32 = 1.4;
const TOKENS_PER_SAMPLE: usize = 6;
const BEAM: f32 = 10.0;
pub const MAX_GLIDE_LETTERS: usize = 30;
const DWELL_MS: u32 = 120;
const DWELL_RADIUS: f32 = 0.35;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GlidePoint {
pub x: f32,
pub y: f32,
pub time_ms: Option<u32>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct GlideKeyboard {
pub centers: [(f32, f32); 26],
pub key_width: f32,
pub key_height: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct GlideHypothesis {
pub letters: String,
pub cost: f32,
}
#[derive(Debug, Clone, Copy)]
struct Sample {
x: f32,
y: f32,
dwell: bool,
}
impl GlideKeyboard {
pub fn is_valid(&self) -> bool {
self.key_width.is_finite()
&& self.key_height.is_finite()
&& self.key_width > 0.0
&& self.key_height > 0.0
&& self
.centers
.iter()
.all(|(x, y)| x.is_finite() && y.is_finite())
}
fn normalized_centers(&self) -> [(f32, f32); 26] {
self.centers
.map(|(x, y)| (x / self.key_width, y / self.key_height))
}
}
pub fn decode_glide(
keyboard: &GlideKeyboard,
points: &[GlidePoint],
limit: usize,
) -> Vec<GlideHypothesis> {
if limit == 0
|| points.len() < 2
|| !keyboard.is_valid()
|| points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite())
{
return Vec::new();
}
let normalized: Vec<GlidePoint> = points
.iter()
.map(|p| GlidePoint {
x: p.x / keyboard.key_width,
y: p.y / keyboard.key_height,
time_ms: p.time_ms,
})
.collect();
let samples = resample(&normalized);
if samples.len() < 2 {
return Vec::new();
}
Decoder::new(keyboard.normalized_centers(), &samples).run(limit)
}
fn dwell_positions(points: &[GlidePoint], arc: &[f32]) -> Vec<f32> {
let mut dwells = Vec::new();
let mut start = 0;
while start < points.len() {
let Some(begin) = points[start].time_ms else {
start += 1;
continue;
};
let mut end = start;
while end + 1 < points.len() && distance(points[start], points[end + 1]) <= DWELL_RADIUS {
end += 1;
}
let rested = points[end]
.time_ms
.is_some_and(|stop| stop.saturating_sub(begin) >= DWELL_MS);
if rested {
dwells.push((arc[start] + arc[end]) / 2.0);
start = end + 1;
} else {
start += 1;
}
}
dwells
}
fn distance(a: GlidePoint, b: GlidePoint) -> f32 {
((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt()
}
fn resample(points: &[GlidePoint]) -> Vec<Sample> {
let mut arc = Vec::with_capacity(points.len());
let mut length = 0.0;
arc.push(0.0);
for pair in points.windows(2) {
length += distance(pair[0], pair[1]);
arc.push(length);
}
let step = SAMPLE_STEP.max(length / (MAX_SAMPLES - 1) as f32);
let count = ((length / step).ceil() as usize + 1).clamp(2, MAX_SAMPLES);
let mut samples = Vec::with_capacity(count);
let mut segment = 0;
for index in 0..count {
let target = length * index as f32 / (count - 1) as f32;
while segment + 2 < arc.len() && arc[segment + 1] < target {
segment += 1;
}
let (a, b) = (points[segment], points[segment + 1]);
let span = arc[segment + 1] - arc[segment];
let t = if span > 0.0 {
((target - arc[segment]) / span).clamp(0.0, 1.0)
} else {
0.0
};
samples.push(Sample {
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
dwell: false,
});
}
if length > 0.0 {
for dwell in dwell_positions(points, &arc) {
let index = ((dwell / length) * (count - 1) as f32).round() as usize;
samples[index.min(count - 1)].dwell = true;
}
}
samples
}
struct SyllableTrie {
children: Vec<[u16; 26]>,
intact: Vec<bool>,
masks: Vec<u32>,
}
fn syllable_trie() -> &'static SyllableTrie {
static TRIE: OnceLock<SyllableTrie> = OnceLock::new();
TRIE.get_or_init(|| {
let mut trie = SyllableTrie {
children: vec![[0; 26]],
intact: vec![false],
masks: Vec::new(),
};
for syllable in intact_pinyin_list() {
let mut node = 0;
for &letter in syllable.as_bytes() {
let slot = usize::from(letter - b'a');
if trie.children[node][slot] == 0 {
trie.children.push([0; 26]);
trie.intact.push(false);
trie.children[node][slot] =
u16::try_from(trie.children.len() - 1).expect("the syllable trie is small");
}
node = usize::from(trie.children[node][slot]);
}
trie.intact[node] = true;
}
trie.masks = trie
.children
.iter()
.map(|children| {
(0..26)
.filter(|&letter| children[letter] != 0)
.fold(0, |mask, letter| mask | 1 << letter)
})
.collect();
trie
})
}
impl SyllableTrie {
fn followers(&self, states: &[u16]) -> u32 {
states
.iter()
.fold(0, |mask, &state| mask | self.masks[usize::from(state)])
}
fn extend(&self, states: &[u16], letter: u8, next: &mut Vec<u16>) {
next.clear();
for &state in states {
let child = self.children[usize::from(state)][usize::from(letter)];
if child == 0 {
continue;
}
let node = usize::from(child);
if self.intact[node] && !next.contains(&0) {
next.push(0);
}
if self.children[node].iter().any(|&grand| grand != 0) && !next.contains(&child) {
next.push(child);
}
}
}
}
struct Prefix {
letters: Vec<u8>,
states: Vec<u16>,
costs: Vec<(usize, f32)>,
}
impl Prefix {
fn empty() -> Self {
Self {
letters: Vec::new(),
states: Vec::new(),
costs: Vec::new(),
}
}
}
struct PruneScratch {
at: Vec<Vec<(f32, usize, usize)>>,
keep: Vec<Vec<bool>>,
}
impl PruneScratch {
fn new(count: usize) -> Self {
Self {
at: (0..count).map(|_| Vec::new()).collect(),
keep: Vec::new(),
}
}
}
struct Decoder {
align: [Option<Vec<f32>>; 26],
reach: [Vec<usize>; 26],
lines: Vec<Option<Vec<f32>>>,
count: usize,
}
impl Decoder {
fn new(centers: [(f32, f32); 26], samples: &[Sample]) -> Self {
let length: f32 = samples
.windows(2)
.map(|pair| ((pair[0].x - pair[1].x).powi(2) + (pair[0].y - pair[1].y).powi(2)).sqrt())
.sum();
let weight = length / (samples.len() - 1) as f32;
let near = NEAR_DISTANCE * NEAR_DISTANCE;
let squared: [Vec<f32>; 26] = std::array::from_fn(|letter| {
let (cx, cy) = centers[letter];
samples
.iter()
.map(|s| (s.x - cx).powi(2) + (s.y - cy).powi(2))
.collect()
});
let reach: [Vec<usize>; 26] = std::array::from_fn(|letter| {
(0..samples.len())
.filter(|&j| squared[letter][j] <= near)
.collect()
});
let align: [Option<Vec<f32>>; 26] = std::array::from_fn(|letter| {
(!reach[letter].is_empty())
.then(|| squared[letter].iter().map(|d| d / ALIGN_SCALE).collect())
});
let mut lines = vec![None; 26 * 26];
for from in 0..26 {
for to in 0..26 {
if align[from].is_none() || align[to].is_none() {
continue;
}
let a = centers[from];
let b = centers[to];
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let span = dx * dx + dy * dy;
let length = span.sqrt();
let mut total = 0.0;
let mut previous: Option<&Sample> = None;
lines[from * 26 + to] = Some(
samples
.iter()
.map(|s| {
if let Some(p) = previous.filter(|_| length > 0.0) {
let along = ((s.x - p.x) * dx + (s.y - p.y) * dy) / length;
total += (-along).max(0.0) * BACKTRACK_COST;
}
previous = Some(s);
let t = if span > 0.0 {
(((s.x - a.0) * dx + (s.y - a.1) * dy) / span).clamp(0.0, 1.0)
} else {
0.0
};
let off = (s.x - a.0 - dx * t).powi(2) + (s.y - a.1 - dy * t).powi(2);
total += off / LINE_SCALE * weight;
if s.dwell {
total += SKIPPED_DWELL_COST;
}
total
})
.collect(),
);
}
}
Self {
align,
reach,
lines,
count: samples.len(),
}
}
fn run(self, limit: usize) -> Vec<GlideHypothesis> {
let trie = syllable_trie();
let count = self.count;
let last = count - 1;
let near: Vec<u8> = (0..26u8)
.filter(|&letter| self.align[usize::from(letter)].is_some())
.collect();
let mut states = Vec::new();
let mut level: Vec<Prefix> = Vec::new();
for &letter in &near {
if self.reach[usize::from(letter)].first() != Some(&0) {
continue;
}
trie.extend(&[0], letter, &mut states);
if states.is_empty() {
continue;
}
let cost = self.align[usize::from(letter)].as_ref().expect("near")[0] + LETTER_COST;
level.push(Prefix {
letters: vec![letter],
states: states.clone(),
costs: vec![(0, cost)],
});
}
let mut finals: Vec<GlideHypothesis> = Vec::new();
let mut floors = vec![f32::INFINITY; count];
let mut prune_scratch = PruneScratch::new(count);
let mut recycled = Vec::new();
for _ in 1..MAX_GLIDE_LETTERS {
floors.fill(f32::INFINITY);
let mut next = Vec::new();
for prefix in &level {
let from = usize::from(*prefix.letters.last().expect("prefixes are never empty"));
let followers = trie.followers(&prefix.states);
let earliest = prefix.costs[0].0;
for &letter in &near {
if followers & 1 << letter == 0
|| self.reach[usize::from(letter)].last() <= Some(&earliest)
{
continue;
}
trie.extend(&prefix.states, letter, &mut states);
if states.is_empty() {
continue;
}
let lines = self.lines[from * 26 + usize::from(letter)]
.as_deref()
.expect("lines are filled for every pair of near letters");
let align = self.align[usize::from(letter)].as_deref().expect("near");
let step = if usize::from(letter) == from {
LETTER_COST + REPEAT_COST
} else {
LETTER_COST
};
let mut candidate = recycled.pop().unwrap_or_else(Prefix::empty);
candidate.costs.clear();
let mut best = f32::INFINITY;
let mut taken = 0;
for &j in &self.reach[usize::from(letter)] {
while taken < prefix.costs.len() && prefix.costs[taken].0 < j {
let (i, cost) = prefix.costs[taken];
best = best.min(cost - lines[i]);
taken += 1;
}
if !best.is_finite() {
continue;
}
let cost = best + lines[j - 1] + align[j] + step;
if cost <= floors[j] + BEAM {
floors[j] = floors[j].min(cost);
candidate.costs.push((j, cost));
}
}
if candidate.costs.is_empty() {
recycled.push(candidate);
continue;
}
candidate.letters.clear();
candidate.letters.extend_from_slice(&prefix.letters);
candidate.letters.push(letter);
candidate.states.clear();
candidate.states.extend_from_slice(&states);
next.push(candidate);
}
}
recycled.append(&mut level);
prune(&mut next, &mut prune_scratch, &mut recycled);
finals.extend(next.iter().filter_map(|prefix| {
let &(at, cost) = prefix.costs.last()?;
(at == last && prefix.states.contains(&0)).then(|| GlideHypothesis {
letters: prefix
.letters
.iter()
.map(|&l| char::from(b'a' + l))
.collect(),
cost,
})
}));
if finals.len() >= limit {
finals.select_nth_unstable_by(limit - 1, |a, b| a.cost.total_cmp(&b.cost));
finals.truncate(limit);
let ceiling = finals
.iter()
.map(|hypothesis| hypothesis.cost)
.fold(f32::NEG_INFINITY, f32::max);
for prefix in &mut next {
prefix.costs.retain(|&(_, cost)| cost < ceiling);
}
let current = std::mem::take(&mut next);
next = current
.into_iter()
.filter_map(|prefix| {
if prefix.costs.is_empty() {
recycled.push(prefix);
None
} else {
Some(prefix)
}
})
.collect();
}
if next.is_empty() {
break;
}
level = next;
}
finals.sort_by(|a, b| {
a.cost
.total_cmp(&b.cost)
.then_with(|| a.letters.cmp(&b.letters))
});
finals.truncate(limit);
finals
}
}
fn prune(prefixes: &mut Vec<Prefix>, scratch: &mut PruneScratch, recycled: &mut Vec<Prefix>) {
for tokens in &mut scratch.at {
tokens.clear();
}
for (p, prefix) in prefixes.iter().enumerate() {
for (entry, &(j, cost)) in prefix.costs.iter().enumerate() {
scratch.at[j].push((cost, p, entry));
}
}
scratch.keep.resize_with(prefixes.len(), Vec::new);
for (prefix, keep) in prefixes.iter().zip(&mut scratch.keep) {
keep.clear();
keep.resize(prefix.costs.len(), false);
}
for tokens in &mut scratch.at {
if tokens.len() > TOKENS_PER_SAMPLE {
tokens.select_nth_unstable_by(TOKENS_PER_SAMPLE - 1, |a, b| a.0.total_cmp(&b.0));
tokens.truncate(TOKENS_PER_SAMPLE);
}
let floor = tokens
.iter()
.map(|token| token.0)
.fold(f32::INFINITY, f32::min);
for &(cost, p, entry) in tokens.iter() {
if cost <= floor + BEAM {
scratch.keep[p][entry] = true;
}
}
}
for (prefix, keep) in prefixes.iter_mut().zip(&scratch.keep) {
let mut flags = keep.iter();
prefix
.costs
.retain(|_| *flags.next().expect("one flag per entry"));
}
let current = std::mem::take(prefixes);
*prefixes = Vec::with_capacity(current.len());
for prefix in current {
if prefix.costs.is_empty() {
recycled.push(prefix);
} else {
prefixes.push(prefix);
}
}
}
struct SyllableSplits<'a> {
syllables: Vec<&'a str>,
ranges: Vec<SplitRange>,
}
struct SplitRange {
range: std::ops::Range<usize>,
key: String,
}
impl<'a> SyllableSplits<'a> {
fn new(letters: &'a str, limit: usize) -> Self {
Self {
syllables: Vec::with_capacity(letters.len().saturating_mul(limit)),
ranges: Vec::with_capacity(limit),
}
}
fn iter(&self) -> impl Iterator<Item = &[&'a str]> {
self.ranges
.iter()
.map(|range| &self.syllables[range.range.clone()])
}
}
fn syllable_splits(letters: &str, limit: usize) -> SyllableSplits<'_> {
fn walk<'a>(
rest: &'a str,
split: &mut Vec<&'a str>,
found: &mut SyllableSplits<'a>,
limit: usize,
) {
if found.ranges.len() >= limit {
return;
}
if rest.is_empty() {
let start = found.syllables.len();
found.syllables.extend_from_slice(split);
found.ranges.push(SplitRange {
range: start..found.syllables.len(),
key: split.join("'"),
});
return;
}
for end in (1..=rest.len().min(MAX_SYLLABLE_LENGTH)).rev() {
if is_intact(&rest[..end]) {
split.push(&rest[..end]);
walk(&rest[end..], split, found, limit);
split.pop();
}
}
}
let mut found = SyllableSplits::new(letters, limit);
let mut split = Vec::with_capacity(letters.len());
walk(letters, &mut split, &mut found, limit);
found
}
pub fn rank_by_dictionary(
hypotheses: Vec<GlideHypothesis>,
weights: impl FnOnce(&[String]) -> HashMap<String, i64>,
) -> Vec<GlideHypothesis> {
const SPLITS: usize = 8;
let splits: Vec<SyllableSplits<'_>> = hypotheses
.iter()
.map(|hypothesis| syllable_splits(&hypothesis.letters, SPLITS))
.collect();
let key_capacity = splits
.iter()
.map(|hypothesis| {
hypothesis
.iter()
.map(|split| split.len() + 1)
.sum::<usize>()
})
.sum();
let mut unique = HashSet::with_capacity(key_capacity);
let mut keys = Vec::with_capacity(key_capacity);
for hypothesis in &splits {
for range in &hypothesis.ranges {
if unique.insert(range.key.as_str()) {
keys.push(range.key.clone());
}
for syllable in &hypothesis.syllables[range.range.clone()] {
if unique.insert(*syllable) {
keys.push((*syllable).to_owned());
}
}
}
}
keys.sort();
let known = weights(&keys);
let scores: Vec<f32> = splits
.iter()
.map(|splits| {
let word = splits
.ranges
.iter()
.zip(splits.iter())
.filter_map(|(range, split)| {
known
.get(&range.key)
.map(|&weight| (weight.max(0) as f32).ln_1p())
.map(|weight| weight + WORD_SYLLABLE_BONUS * (split.len() - 1) as f32)
})
.fold(None, |best: Option<f32>, weight| {
Some(best.map_or(weight, |b| b.max(weight)))
});
word.unwrap_or_else(|| {
splits
.iter()
.map(|split| {
split
.iter()
.map(|syllable| {
known
.get(*syllable)
.map(|&weight| (weight.max(0) as f32).ln_1p())
.unwrap_or(0.0)
})
.fold(f32::INFINITY, f32::min)
})
.fold(0.0, f32::max)
- UNKNOWN_WORD_COST
})
})
.collect();
let mut ranked: Vec<GlideHypothesis> = hypotheses
.into_iter()
.zip(scores)
.map(|(hypothesis, score)| GlideHypothesis {
cost: hypothesis.cost - DICTIONARY_WEIGHT * score,
..hypothesis
})
.collect();
ranked.sort_by(|a, b| {
a.cost
.total_cmp(&b.cost)
.then_with(|| a.letters.cmp(&b.letters))
});
ranked
}
#[cfg(test)]
pub(crate) mod tests;