Skip to main content

msime_engine/zhuyin/
conversion.rs

1//! Conversion of the completed syllables into text: a Viterbi pass over every way of cutting the sequence into dictionary words, with the spans the user pinned through the candidate list kept as chosen.
2
3use crate::error::Result;
4use crate::language_dictionary::LanguageEntry;
5
6/// The most syllables a composition holds. A tone key that would complete one more first commits the leftmost converted word (libchewing's auto-shift).
7pub const MAX_SYLLABLES: usize = 20;
8
9/// Text covering the syllables `start..end`.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct Span {
12    pub start: usize,
13    pub end: usize,
14    /// 这段文字在词库里的键:所用的带调音节以单个空格连接。九键下一个位置可能有多个读音,键说明转换实际用了哪一个。
15    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/// How good a path is, compared field by field. Longer words come first: each span adds the square of its length (msime's own scoring), so one two-syllable word (4) beats two single characters (2) whatever their weights. Among paths with equally long words the summed entry weight decides.
34#[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
50/// 把 `count` 个音节转换成覆盖全部音节的最佳文字段序列。`best(start, end)` 返回音节 `start..end` 的最重词条及其键;`pins` 是互不重叠、必须原样出现的段,其他段不得跨过它们。没有词条的单个音节转换成 `fallback(i)` 给出的读音本身,所以总有一条路径。
51pub 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
71/// 被当前路径分数拒绝的词条仍然存在,不能把它当成缺失词条并生成单音节兜底。
72pub(super) enum Lookup {
73    Missing,
74    Rejected,
75    Found(String, LanguageEntry),
76}
77
78/// 在词条借用期间调用 `can_improve(weight)`,只为能改善路径的词条取得所有权;查询顺序和错误传播与 `convert` 相同。
79pub(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    // `paths[i]` 是转换前 `i` 个音节的最佳路径及结束它的那一段。
86    // 组合长度内的路径表放在栈上,超长直接调用仍使用动态缓冲。
87    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        // 每段带着命中的键。
299        assert_eq!(spans[0].key, "ㄋㄧˇ ㄏㄠˇ");
300        assert_eq!(spans[1].key, "ㄇㄚ˙");
301    }
302
303    // 九键下一个位置有多个读音:`best` 自己在读音里挑,返回的键说明用了哪个;没有词条的单音节用调用方给的读音兜底。
304    #[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                // 逐个组合查,取最重的。
313                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        // Two segmentations of the same lengths: the heavier total wins.
348        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        // A pinned word holds even where the dictionary has no such entry.
435        let pin = span(0, 2, "ㄋㄧˇ ㄏㄠˇ", "妳好");
436        let spans = run(&["ㄋㄧˇ", "ㄏㄠˇ", "ㄊㄞˊ", "ㄨㄢ"], &[pin], &ENTRIES);
437        assert_eq!(texts(&spans), ["妳好", "臺灣"]);
438    }
439}