1use crate::error::Result;
4use crate::language_dictionary::LanguageEntry;
5
6pub const MAX_SYLLABLES: usize = 20;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct Span {
12 pub start: usize,
13 pub end: usize,
14 pub key: String,
16 pub text: String,
17}
18
19impl Span {
20 pub fn len(&self) -> usize {
21 self.end - self.start
22 }
23
24 pub fn is_empty(&self) -> bool {
25 self.start == self.end
26 }
27
28 pub fn overlaps(&self, start: usize, end: usize) -> bool {
29 self.start < end && start < self.end
30 }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
35struct Score {
36 length: u64,
37 weight: i64,
38}
39
40impl Score {
41 fn add(self, span_len: usize, weight: i64) -> Self {
42 let span_len = span_len as u64;
43 Self {
44 length: self.length + span_len * span_len,
45 weight: self.weight.saturating_add(weight),
46 }
47 }
48}
49
50pub fn convert(
52 count: usize,
53 pins: &[Span],
54 mut best: impl FnMut(usize, usize) -> Result<Option<(String, LanguageEntry)>>,
55 fallback: impl FnMut(usize) -> String,
56) -> Result<Vec<Span>> {
57 convert_filtered(
58 count,
59 pins,
60 |start, end, can_improve| {
61 Ok(match best(start, end)? {
62 Some((key, entry)) if can_improve(entry.weight) => Lookup::Found(key, entry),
63 Some(_) => Lookup::Rejected,
64 None => Lookup::Missing,
65 })
66 },
67 fallback,
68 )
69}
70
71pub(super) enum Lookup {
73 Missing,
74 Rejected,
75 Found(String, LanguageEntry),
76}
77
78pub(super) fn convert_filtered(
80 count: usize,
81 pins: &[Span],
82 mut best: impl FnMut(usize, usize, &mut dyn FnMut(i64) -> bool) -> Result<Lookup>,
83 mut fallback: impl FnMut(usize) -> String,
84) -> Result<Vec<Span>> {
85 let mut stack_paths = [const { None }; MAX_SYLLABLES + 1];
88 let mut heap_paths;
89 let paths: &mut [Option<(Score, Option<Span>)>] = if count <= MAX_SYLLABLES {
90 &mut stack_paths[..=count]
91 } else {
92 heap_paths = vec![None; count + 1];
93 &mut heap_paths
94 };
95 paths[0] = Some((
96 Score {
97 length: 0,
98 weight: 0,
99 },
100 None,
101 ));
102 for start in 0..count {
103 let Some((score, _)) = &paths[start] else {
104 continue;
105 };
106 let score = *score;
107 let pin = pins.iter().find(|pin| pin.start == start);
108 if let Some(pin) = pin {
109 consider_span(paths, score, pin.clone(), 0);
110 } else {
111 for end in start + 1..=count {
112 if pins.iter().any(|pin| pin.overlaps(start, end)) {
113 break;
114 }
115 let (key, text, weight) = match best(start, end, &mut |weight| {
116 let arrival = score.add(end - start, weight);
117 paths[end]
118 .as_ref()
119 .is_none_or(|(current, _)| arrival > *current)
120 })? {
121 Lookup::Found(key, entry) => (key, entry.text, entry.weight),
122 Lookup::Missing if end == start + 1 => {
123 let reading = fallback(start);
124 (reading.clone(), reading, 0)
125 }
126 Lookup::Missing | Lookup::Rejected => continue,
127 };
128 consider_span(
129 paths,
130 score,
131 Span {
132 start,
133 end,
134 key,
135 text,
136 },
137 weight,
138 );
139 }
140 }
141 }
142 let mut spans = Vec::with_capacity(count);
143 let mut end = count;
144 while end > 0 {
145 let span = paths[end]
146 .as_mut()
147 .and_then(|(_, span)| span.take())
148 .expect("every position is reachable through single-syllable spans or pins");
149 end = span.start;
150 spans.push(span);
151 }
152 spans.reverse();
153 Ok(spans)
154}
155
156fn consider_span(
157 paths: &mut [Option<(Score, Option<Span>)>],
158 score: Score,
159 span: Span,
160 weight: i64,
161) {
162 let arrival = score.add(span.len(), weight);
163 let end = span.end;
164 if paths[end]
165 .as_ref()
166 .is_none_or(|(current, _)| arrival > *current)
167 {
168 paths[end] = Some((arrival, Some(span)));
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use std::collections::HashMap;
175
176 use super::*;
177
178 fn dictionary(entries: &[(&str, &str, i64)]) -> HashMap<String, LanguageEntry> {
179 let mut best: HashMap<String, LanguageEntry> = HashMap::new();
180 for (key, text, weight) in entries {
181 let entry = LanguageEntry {
182 text: (*text).to_owned(),
183 weight: *weight,
184 };
185 if best
186 .get(*key)
187 .is_none_or(|current| current.weight < *weight)
188 {
189 best.insert((*key).to_owned(), entry);
190 }
191 }
192 best
193 }
194
195 fn texts(spans: &[Span]) -> Vec<&str> {
196 spans.iter().map(|span| span.text.as_str()).collect()
197 }
198
199 fn run(syllables: &[&str], pins: &[Span], entries: &[(&str, &str, i64)]) -> Vec<Span> {
200 let best = dictionary(entries);
201 convert(
202 syllables.len(),
203 pins,
204 |start, end| {
205 let key = syllables[start..end].join(" ");
206 Ok(best.get(&key).cloned().map(|entry| (key, entry)))
207 },
208 |index| syllables[index].to_owned(),
209 )
210 .unwrap()
211 }
212
213 fn span(start: usize, end: usize, key: &str, text: &str) -> Span {
214 Span {
215 start,
216 end,
217 key: key.to_owned(),
218 text: text.to_owned(),
219 }
220 }
221
222 #[test]
223 fn bounded_conversion_does_not_allocate_path_storage() {
224 let pin = span(0, MAX_SYLLABLES, "合成讀音", "合成文字");
225 let (spans, allocations) = crate::ime::personal_rerank::allocations::count(|| {
226 convert(
227 MAX_SYLLABLES,
228 std::slice::from_ref(&pin),
229 |_, _| panic!("完整钉住的组合不应查询词库"),
230 |_| panic!("完整钉住的组合不应生成兜底读音"),
231 )
232 .unwrap()
233 });
234 assert_eq!(allocations, 3, "只应分配返回向量和钉住词条的两个字符串");
235 assert_eq!(spans, [pin]);
236
237 let (spans, allocations) = crate::ime::personal_rerank::allocations::count(|| {
238 convert(
239 0,
240 &[],
241 |_, _| panic!("空组合不应查询词库"),
242 |_| panic!("空组合不应生成兜底读音"),
243 )
244 .unwrap()
245 });
246 assert!(spans.is_empty());
247 assert_eq!(allocations, 0);
248 }
249
250 #[test]
251 fn conversion_accepts_more_than_the_composition_limit() {
252 let syllables = vec!["ㄋㄧˇ"; MAX_SYLLABLES + 1];
253 let spans = run(&syllables, &[], &ENTRIES);
254 assert_eq!(spans.len(), syllables.len());
255 assert!(spans.iter().all(|span| span.text == "你"));
256 assert_eq!((spans[0].start, spans[0].end), (0, 1));
257 assert_eq!(spans.last().unwrap().end, syllables.len());
258 }
259
260 #[test]
261 fn direct_arrival_updates_keep_the_best_path() {
262 let mut paths = vec![None; 2];
263 let score = Score {
264 length: 0,
265 weight: 0,
266 };
267 consider_span(&mut paths, score, span(0, 1, "ㄉㄧ", "低"), 1);
268 consider_span(&mut paths, score, span(0, 1, "ㄍㄠ", "高"), 2);
269 assert_eq!(
270 paths[1].as_ref().map(|(score, _)| *score),
271 Some(score.add(1, 2))
272 );
273 assert_eq!(
274 paths[1]
275 .as_ref()
276 .and_then(|(_, span)| span.as_ref())
277 .map(|span| span.text.as_str()),
278 Some("高")
279 );
280 }
281
282 const ENTRIES: [(&str, &str, i64); 7] = [
283 ("ㄋㄧˇ", "你", 1000),
284 ("ㄏㄠˇ", "好", 2000),
285 ("ㄋㄧˇ ㄏㄠˇ", "你好", 10),
286 ("ㄇㄚ˙", "嗎", 800),
287 ("ㄊㄞˊ", "臺", 400),
288 ("ㄨㄢ", "灣", 300),
289 ("ㄊㄞˊ ㄨㄢ", "臺灣", 800),
290 ];
291
292 #[test]
293 fn longer_words_win_over_heavier_characters() {
294 let spans = run(&["ㄋㄧˇ", "ㄏㄠˇ", "ㄇㄚ˙"], &[], &ENTRIES);
295 assert_eq!(texts(&spans), ["你好", "嗎"]);
296 assert_eq!((spans[0].start, spans[0].end), (0, 2));
297 assert_eq!((spans[1].start, spans[1].end), (2, 3));
298 assert_eq!(spans[0].key, "ㄋㄧˇ ㄏㄠˇ");
300 assert_eq!(spans[1].key, "ㄇㄚ˙");
301 }
302
303 #[test]
305 fn spans_carry_the_key_best_chose_and_the_fallback_reading() {
306 let positions: [&[&str]; 3] = [&["ㄌㄧˇ", "ㄋㄧˇ"], &["ㄏㄠˇ"], &["ㄅㄧㄤ", "ㄆㄧㄤ"]];
307 let best = dictionary(&ENTRIES);
308 let spans = convert(
309 positions.len(),
310 &[],
311 |start, end| {
312 let mut keys = vec![String::new()];
314 for readings in &positions[start..end] {
315 keys = keys
316 .iter()
317 .flat_map(|prefix| {
318 readings.iter().map(move |reading| {
319 if prefix.is_empty() {
320 (*reading).to_owned()
321 } else {
322 format!("{prefix} {reading}")
323 }
324 })
325 })
326 .collect();
327 }
328 Ok(keys
329 .into_iter()
330 .filter_map(|key| best.get(&key).cloned().map(|entry| (key, entry)))
331 .max_by_key(|(_, entry)| entry.weight))
332 },
333 |index| positions[index][1].to_owned(),
334 )
335 .unwrap();
336 assert_eq!(
337 spans,
338 [
339 span(0, 2, "ㄋㄧˇ ㄏㄠˇ", "你好"),
340 span(2, 3, "ㄆㄧㄤ", "ㄆㄧㄤ")
341 ]
342 );
343 }
344
345 #[test]
346 fn weights_decide_between_equally_long_words() {
347 let entries = [
349 ("a", "A", 1),
350 ("b c", "BC", 5),
351 ("a b", "AB", 9),
352 ("c", "C", 1),
353 ];
354 assert_eq!(texts(&run(&["a", "b", "c"], &[], &entries)), ["AB", "C"]);
355 }
356
357 #[test]
358 fn rejected_negative_single_rows_do_not_become_fallbacks() {
359 let entries = [
360 ("a", "A", 0),
361 ("b", "B", 0),
362 ("c", "C", 0),
363 ("d", "D", -20),
364 ("b c", "BC", 0),
365 ("c d", "CD", -10),
366 ];
367 assert_eq!(
368 texts(&run(&["a", "b", "c", "d"], &[], &entries)),
369 ["A", "B", "CD"]
370 );
371 }
372
373 #[test]
374 fn equal_and_saturated_scores_keep_the_first_arrival() {
375 for weight in [1, i64::MAX] {
376 let entries = [
377 ("a", "A", weight),
378 ("b", "B", weight),
379 ("c", "C", weight),
380 ("a b", "AB", weight),
381 ("b c", "BC", weight),
382 ];
383 assert_eq!(texts(&run(&["a", "b", "c"], &[], &entries)), ["A", "BC"]);
384 }
385 }
386
387 #[test]
388 fn losing_interval_queries_still_propagate_errors() {
389 let mut visited = Vec::new();
390 let result = convert(
391 3,
392 &[],
393 |start, end| {
394 visited.push((start, end));
395 if start == 1 {
396 return Err(crate::error::EngineError::failed("合成查询失败"));
397 }
398 Ok(Some((
399 "合成讀音".to_owned(),
400 LanguageEntry {
401 text: "合成文字".to_owned(),
402 weight: 1,
403 },
404 )))
405 },
406 |_| panic!("已有词条不应生成兜底读音"),
407 );
408 assert_eq!(visited, [(0, 1), (0, 2), (0, 3), (1, 2)]);
409 assert!(matches!(
410 result,
411 Err(crate::error::EngineError::Failed(message)) if message == "合成查询失败"
412 ));
413 }
414
415 #[test]
416 fn unknown_syllables_convert_to_themselves() {
417 let spans = run(&["ㄅㄧㄤ", "ㄋㄧˇ"], &[], &ENTRIES);
418 assert_eq!(texts(&spans), ["ㄅㄧㄤ", "你"]);
419 assert_eq!(spans.capacity(), spans.len());
420 assert!(run(&[], &[], &ENTRIES).is_empty());
421 }
422
423 #[test]
424 fn pins_are_kept_and_never_crossed() {
425 let pin = span(1, 2, "ㄏㄠˇ", "郝");
426 let spans = run(
427 &["ㄋㄧˇ", "ㄏㄠˇ", "ㄇㄚ˙"],
428 std::slice::from_ref(&pin),
429 &ENTRIES,
430 );
431 assert_eq!(texts(&spans), ["你", "郝", "嗎"]);
432 assert_eq!(spans[1], pin);
433
434 let pin = span(0, 2, "ㄋㄧˇ ㄏㄠˇ", "妳好");
436 let spans = run(&["ㄋㄧˇ", "ㄏㄠˇ", "ㄊㄞˊ", "ㄨㄢ"], &[pin], &ENTRIES);
437 assert_eq!(texts(&spans), ["妳好", "臺灣"]);
438 }
439}