msime-engine 0.2.0

Input method engine for Chinese and other languages: quanpin, shuangpin, nine-key, wubi, zhuyin, Cantonese, Japanese, Korean and more, ranked over a word lattice with typo correction and learning from what the user picks. The engine of 水杉输入法 (Metasequoia IME).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
//! 候选排序的唯一实现:句子模型重排、整句次选读法的后移,以及交给重排器的上文窗口。
//!
//! 这里只做决策,不碰候选数组:`rerank_pick` 返回应移到首位的下标,`runner_up_order` 返回一个排列(新座位 -> 旧座位)。`msime-input-runtime` 把结果同步应用到候选元数据和引擎座位映射上,网页引擎(`msime-engine-wasm`)也调用同一组函数,两处的排序因此不会各自漂移。
//!
//! 这是 msime 自己的产品胶水,不在引擎文档化的 API 里:第三方拿到的是引擎的原始顺序(核心 `Session` 的候选),要不要再排、怎么排由它自己决定。

use crate::SchemeType;

pub use chinese_ime_lm::{CandidateFacts, Reranker, SentenceModel, DICTIONARY_SOURCES};

/// `CandidateSource::Generated`: a whole-sentence path the word lattice assembled. The one source
/// whose members really are alternative readings of the same key.
pub const LATTICE_SOURCE: u8 = 8;

/// `CandidateSource::QuickPhrase`, `Emoji` and `Kaomoji`: entries a keyword finds in a catalog, listed in the catalog's order. They are not readings of the key, so the reranker neither compares them nor promotes them: typing `kiss` in kaomoji mode had the model seat the catalog's 646th entry, `French Kiss!(* ̄( ̄ *)`, above its first.
const CATALOG_SOURCES: [u8; 3] = [5, 6, 7];

/// `CandidateSource::AiSuggestion` 和已经不再产生的占位行 `AiPending`:在线座位规则(`normalize_online_slots`)总把 AI 行放回第一页最后一格和列表末尾,模型提它们也没用;更要紧的是,若让 AI 候选参与比较,它一到就可能抢走本来要提到首位的本地候选,回答到达时本地候选的次序就变了(#6888)。
const AI_SEAT_SOURCES: [u8; 2] = [3, 12];

/// `CandidateSource::EnglishDictionary`。混输英文只在拼音拆不开、英文完全匹配(或用户固定在第一位)时才由引擎放到首位,那是引擎有意的安排;句子模型读的是中文,不能因为英文行不算词典命中就把中文行转到它前面。这道检查不分方案和模式:只有英文行的列表(比如专用英文模式、临时英文)首行也是英文,同样不重排,这是有意的:模型读的是中文,比较英文行没有意义。
const ENGLISH_SOURCE: u8 = 4;

/// `CandidateSource::MixedJapanese`:日文混输行。它领先时是引擎判定拼音给不出整段的词,和领先的英文一样不重排;它在别的位置时也不让模型拿它比较、把它提到首位,句子模型读的是中文。
const MIXED_JAPANESE_SOURCE: u8 = 13;

/// 候选列表通常不超过一页;短列表的文本引用放在栈上,较大列表继续走堆缓冲。
const SMALL_RERANK_TEXTS: usize = 128;

/// 静态证据分支只比较前九条真正回答按键的候选。
const STATIC_RERANK_LIMIT: usize = 9;

/// The traits of the scheme behind `scheme`, which the Engine reports as its `SchemeType` ordinal. Only the placeholder snapshot of a failed refresh carries an ordinal no scheme has; each caller decides what that placeholder means, the way the ordinal comparisons this replaces did.
fn scheme_type(scheme: u8) -> Option<SchemeType> {
    SchemeType::from_u8(scheme)
}

/// Whether the runtime may reorder the scheme's candidate list (the sentence model and the runner-up demotion). A scheme whose selection goes straight to the document instead of being held as phrase progress (the Korean Hanja list) lists its table in frequency order, not readings of one sentence the model can compare, and that order is the one to keep.
pub fn reorders_candidates(scheme: u8) -> bool {
    scheme_type(scheme).is_none_or(SchemeType::holds_phrase_progress)
}

/// 方案的纠错行能不能被重排器提到首位。双拼的纠错整句按产品取舍一律不抢首选(#6034,引擎把它排在整句块之后),所以重排时它既不参与比较,也不让整张表失去词典命中的豁免;全拼的纠错行本来就允许领先(`typo_lead_margin`),照旧。
fn corrections_may_lead(scheme: u8) -> bool {
    scheme_type(scheme) != Some(SchemeType::Shuangpin)
}

/// 原地应用排列,`order` 将每个新座位映射到旧座位。
///
/// 按循环交换候选所有权,不复制元素。记录已访问座位,让每条循环只遍历一次;
/// 短列表用栈缓冲,大列表才申请辅助缓冲,恒等排列直接返回。
pub fn apply_order<T>(items: &mut [T], order: &[usize]) {
    debug_assert_eq!(items.len(), order.len());
    apply_order_with(order, |left, right| items.swap(left, right));
}

/// 按排列的循环调用交换操作,让调用方同步移动多个并行数组。
///
/// `order` 必须是新座位到旧座位的合法排列;交换操作涉及的数组长度必须与它一致。
/// 每条循环只遍历一次,共用访问标记缓冲,恒等排列不调用交换操作。
pub fn apply_order_with(order: &[usize], mut swap: impl FnMut(usize, usize)) {
    if order.iter().enumerate().all(|(seat, row)| seat == *row) {
        return;
    }
    let mut stack_visited = [false; 128];
    let mut heap_visited;
    let visited = if order.len() <= stack_visited.len() {
        &mut stack_visited[..order.len()]
    } else {
        heap_visited = vec![false; order.len()];
        &mut heap_visited[..]
    };
    for start in 0..order.len() {
        if visited[start] {
            continue;
        }
        visited[start] = true;
        let mut current = start;
        while order[current] != start {
            let next = order[current];
            swap(current, next);
            visited[next] = true;
            current = next;
        }
    }
}

/// Keep the Engine-index mapping empty while the cached candidates are still in Engine order.
/// Reordering paths call this immediately before their first permutation, so ordinary keystrokes
/// avoid rebuilding an identity vector on every snapshot.
pub fn ensure_engine_order(engine_order: &mut Vec<usize>, count: usize) {
    if engine_order.len() == count {
        return;
    }
    engine_order.clear();
    engine_order.extend(0..count);
}

pub fn rotate_to_front<T>(items: &mut [T], index: usize) {
    items[..=index].rotate_right(1);
}

/// How far the context handed to a reranker moves at a time once it no longer fits the model.
pub const RERANK_CONTEXT_STEP: usize = 16;

/// The tail of the committed text a reranker should see, trimmed so the window holds still while a candidate grows.
///
/// The reranker keeps the model state for its prefix across keystrokes, keyed on the prefix tokens, and that cache is what keeps a keystroke inside a frame. It trims the context itself to leave room for the longest candidate, so left to do that, a context longer than the window slides by one character every time a candidate gains one — which is most keystrokes that complete a syllable. Every slide is a different prefix, so it reran the prefix and dropped every resume point with it, and a keystroke cost 20-45ms instead of about 1ms. Nothing showed it in a short test: the context only outgrows the window after a few sentences in one application, which is when "typing falls behind" was reported.
///
/// Trimming here, in steps, keeps the prefix identical until the longest candidate crosses a step, and the reranker then finds nothing further to trim because everything handed over already fits. A context that fits whole is handed over unchanged, so short contexts rank exactly as before.
pub fn rerank_context(context: &str, window: usize, longest: usize) -> &str {
    let room = window.saturating_sub(longest + 1);
    let keep = room / RERANK_CONTEXT_STEP * RERANK_CONTEXT_STEP;
    let count = context.chars().count();
    if count <= room {
        return context;
    }
    let skip = count - keep;
    context
        .char_indices()
        .nth(skip)
        .map_or("", |(start, _)| &context[start..])
}

/// 排序决策读取的一行候选:文字、来源序号(`CandidateSource`),是否回答整个按键串,是否是纠错后的读法。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OrderRow<'a> {
    pub text: &'a str,
    pub source: u8,
    pub answers_key: bool,
    pub corrected: bool,
}

/// 词网格的自然对数总分,去掉共同的音节奖励;保留整数毫 nat 与字数避免快照丢失精度。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct StaticEvidence {
    pub log_prob_milli_nats: i64,
    pub characters: usize,
}

impl StaticEvidence {
    fn mean(self) -> Option<f64> {
        (self.characters > 0)
            .then(|| self.log_prob_milli_nats as f64 / 1000.0 / self.characters as f64)
    }
}

/// 排序决策读取的候选行来源。连续的 `OrderRow` 数组和快照里的并行数组都可以实现它。
pub trait OrderRowSource<'a> {
    fn len(&self) -> usize;
    fn get(&self, index: usize) -> OrderRow<'a>;

    /// 没有静态证据的调用方继续使用原模型决策,不从来源或行位置猜一个分数。
    fn static_evidence(&self, _index: usize) -> Option<StaticEvidence> {
        None
    }

    fn is_empty(&self) -> bool {
        self.len() == 0
    }
}

impl<'a> OrderRowSource<'a> for [OrderRow<'a>] {
    fn len(&self) -> usize {
        <[OrderRow<'a>]>::len(self)
    }

    fn get(&self, index: usize) -> OrderRow<'a> {
        self[index]
    }
}

impl<'a> OrderRowSource<'a> for Vec<OrderRow<'a>> {
    fn len(&self) -> usize {
        self.as_slice().len()
    }

    fn get(&self, index: usize) -> OrderRow<'a> {
        self[index]
    }
}

impl<'a, const N: usize> OrderRowSource<'a> for [OrderRow<'a>; N] {
    fn len(&self) -> usize {
        N
    }

    fn get(&self, index: usize) -> OrderRow<'a> {
        self[index]
    }
}

/// 为已有候选行附上可选的静态证据;长度不一致时整份证据失效。
pub struct ScoredOrderRows<'s, R: ?Sized> {
    rows: &'s R,
    evidence: &'s [Option<StaticEvidence>],
}

impl<'s, R: ?Sized> ScoredOrderRows<'s, R> {
    pub fn new(rows: &'s R, evidence: &'s [Option<StaticEvidence>]) -> Self {
        Self { rows, evidence }
    }
}

impl<'a, R: OrderRowSource<'a> + ?Sized> OrderRowSource<'a> for ScoredOrderRows<'_, R> {
    fn len(&self) -> usize {
        self.rows.len()
    }
    fn get(&self, index: usize) -> OrderRow<'a> {
        self.rows.get(index)
    }
    fn static_evidence(&self, index: usize) -> Option<StaticEvidence> {
        if self.evidence.len() != self.rows.len() {
            return None;
        }
        self.evidence.get(index).copied().flatten()
    }
}

/// 借用快照里的并行候选数组,避免排序前复制一份行数组。
pub struct ParallelOrderRows<'a> {
    texts: &'a [String],
    sources: &'a [u8],
    answers_key: &'a [bool],
    corrected: &'a [bool],
    static_evidence: Option<&'a [Option<StaticEvidence>]>,
}

/// 找出实际交给模型比较的候选中最长的一行。
///
/// `Reranker::best_where` 只比较前九条回答当前按键的候选;比较集合之后的长候选不能影响上下文窗口,否则会让模型每次无关的长行出现时重建前缀。
fn longest_rerank_candidate<'a, R, F>(rows: &R, answers_key: &F) -> usize
where
    R: OrderRowSource<'a> + ?Sized,
    F: Fn(OrderRow<'a>) -> bool,
{
    (0..rows.len())
        .map(|index| rows.get(index))
        .filter(|row| answers_key(*row))
        .take(STATIC_RERANK_LIMIT)
        .map(|row| row.text.chars().count())
        .max()
        .unwrap_or(0)
}

impl<'a> ParallelOrderRows<'a> {
    pub fn new(
        texts: &'a [String],
        sources: &'a [u8],
        answers_key: &'a [bool],
        corrected: &'a [bool],
    ) -> Self {
        Self {
            texts,
            sources,
            answers_key,
            corrected,
            static_evidence: None,
        }
    }

    pub fn with_static_evidence(mut self, evidence: &'a [Option<StaticEvidence>]) -> Self {
        self.static_evidence = (evidence.len() == self.texts.len()).then_some(evidence);
        self
    }
}

impl<'a> OrderRowSource<'a> for ParallelOrderRows<'a> {
    fn len(&self) -> usize {
        self.texts.len()
    }

    fn static_evidence(&self, index: usize) -> Option<StaticEvidence> {
        self.static_evidence?.get(index).copied().flatten()
    }

    fn get(&self, index: usize) -> OrderRow<'a> {
        OrderRow {
            text: &self.texts[index],
            source: self.sources[index],
            answers_key: self.answers_key[index],
            corrected: self.corrected[index],
        }
    }
}

fn row_answers_key(scheme: u8, row: OrderRow<'_>) -> bool {
    row.answers_key
        && !CATALOG_SOURCES.contains(&row.source)
        && !AI_SEAT_SOURCES.contains(&row.source)
        && row.source != MIXED_JAPANESE_SOURCE
        && (corrections_may_lead(scheme) || !row.corrected)
}

fn any_corrected_key<'a, R: OrderRowSource<'a> + ?Sized>(scheme: u8, rows: &'a R) -> bool {
    corrections_may_lead(scheme) && (0..rows.len()).any(|index| rows.get(index).corrected)
}

/// 判断 `rerank_pick` 是否需要运行模型,提前拒绝时宿主无需把停顿模型移出会话线程。
///
/// 韩文汉字候选按单音节词频排表,不能用中文模型评分。五笔码表回答的列表先排表码行,
/// 再追加拼音纠错行;若让纠错规则撤销表码首选的豁免,就会把 `dyn` 的「态」挤到
/// `dynn` 的「太快」之后。只有完全由拼音兜底回答的五笔列表才按拼音重排。
pub fn rerank_needs_model<'a, R: OrderRowSource<'a> + ?Sized>(
    scheme: u8,
    answered_by_pinyin_fallback: bool,
    rows: &'a R,
) -> bool {
    if !reorders_candidates(scheme) {
        return false;
    }
    if scheme_type(scheme) == Some(SchemeType::Wubi) && !answered_by_pinyin_fallback {
        return false;
    }
    if rows.len() < 2 {
        return false;
    }
    let leader = rows.get(0);
    // 引擎让英文或日文混输行领先的列表原样保留,见 `ENGLISH_SOURCE`、`MIXED_JAPANESE_SOURCE`。
    if matches!(leader.source, ENGLISH_SOURCE | MIXED_JAPANESE_SOURCE) {
        return false;
    }
    // 首行不回答当前按键时,上游同样拒绝比较,无需准备模型输入。
    if !row_answers_key(scheme, leader) {
        return false;
    }
    // 词典命中携带当前按键的词频;全拼出现任何纠错行时,这份依据不再可信,
    // 整表撤销豁免。双拼纠错行不许领先,也不撤销词典首选的豁免。
    let corrected_key = any_corrected_key(scheme, rows);
    let trusted_leader = DICTIONARY_SOURCES.contains(&leader.source) && !corrected_key;
    !trusted_leader
}

/// rerank() 的决策部分:返回应当移到首位的行下标;None 表示不动。
///
/// `scheme` 是 `SchemeType` 序号;不允许重排的方案(韩文汉字表)返回 None,五笔且 `!answered_by_pinyin_fallback` 时返回 None,行数 < 2 时返回 None,首行是英文时返回 None。`Reranker::best_where` 是 `&mut self`,因此这里取 `&mut`。
pub fn rerank_pick<'a, R: OrderRowSource<'a> + ?Sized>(
    reranker: &mut Reranker,
    context: &str,
    scheme: u8,
    answered_by_pinyin_fallback: bool,
    rows: &'a R,
) -> Option<usize> {
    if !rerank_needs_model(scheme, answered_by_pinyin_fallback, rows) {
        return None;
    }
    let answers_key = |row: OrderRow<'a>| row_answers_key(scheme, row);
    let corrected_key = any_corrected_key(scheme, rows);
    // 只有参与比较的候选需要占用模型上下文窗口。
    let longest = longest_rerank_candidate(rows, &answers_key);
    let context = rerank_context(context, reranker.model().context_length(), longest);
    if rows.static_evidence(0).is_some() {
        let mut considered = [0; STATIC_RERANK_LIMIT];
        let mut statik = [0.0; STATIC_RERANK_LIMIT];
        let mut subset = [""; STATIC_RERANK_LIMIT];
        if let Some(count) =
            fill_static_rerank_inputs(rows, answers_key, &mut considered, &mut statik, &mut subset)
        {
            let neural = reranker.log_probabilities(context, &subset[..count]);
            if neural.len() == count {
                // 模型逐字分与词网格逐字分等权混合,两侧均为自然对数。
                let combined = |index: usize| (statik[index] + f64::from(neural[index])) * 0.5;
                let mut best = 0;
                for index in 1..count {
                    if combined(index) > combined(best) {
                        best = index;
                    }
                }
                return (best != 0).then(|| considered[best]);
            }
        }
    }
    with_row_texts(rows, |texts| {
        reranker.best_where(context, texts, |index| CandidateFacts {
            answers_key: answers_key(rows.get(index)),
            trusted_dictionary_hit: DICTIONARY_SOURCES.contains(&rows.get(index).source)
                && !corrected_key,
        })
    })
}

fn fill_static_rerank_inputs<'a, R, F>(
    rows: &'a R,
    answers_key: F,
    considered: &mut [usize; STATIC_RERANK_LIMIT],
    statik: &mut [f64; STATIC_RERANK_LIMIT],
    subset: &mut [&'a str; STATIC_RERANK_LIMIT],
) -> Option<usize>
where
    R: OrderRowSource<'a> + ?Sized,
    F: Fn(OrderRow<'a>) -> bool,
{
    let mut count = 0;
    for index in 0..rows.len() {
        let row = rows.get(index);
        if !answers_key(row) {
            continue;
        }
        if count == STATIC_RERANK_LIMIT {
            break;
        }
        considered[count] = index;
        statik[count] = rows.static_evidence(index)?.mean()?;
        subset[count] = row.text;
        count += 1;
    }
    (count >= 2).then_some(count)
}

fn with_row_texts<'a, R, T>(rows: &'a R, operation: impl FnOnce(&[&'a str]) -> T) -> T
where
    R: OrderRowSource<'a> + ?Sized,
{
    if rows.len() <= SMALL_RERANK_TEXTS {
        let mut texts = [""; SMALL_RERANK_TEXTS];
        for (index, text) in texts.iter_mut().enumerate().take(rows.len()) {
            *text = rows.get(index).text;
        }
        operation(&texts[..rows.len()])
    } else {
        let mut texts = Vec::with_capacity(rows.len());
        texts.extend((0..rows.len()).map(|index| rows.get(index).text));
        operation(&texts)
    }
}

/// demote_runner_up_readings() 的决策部分:返回 order(新座位 -> 旧座位);恒等排列时返回 None。
///
/// Keep the leading sentence readings together near the top and move the rest of them behind the list.
///
/// The lattice searches several readings of the whole key so that something can choose between them. Leaving all of them at the front fills the candidate page with near-duplicate sentences and pushes the short candidates a user actually wants off it, which is why the search used to be pinned to a single path.
///
/// For a sentence of three or more characters the first page keeps three readings, seated together right after the first one, and only the rest are moved back. One reading was too few once the Google fallback stopped holding a second sentence seat: on sentences-neutral-v1 top5 fell to 0.615 and on sentences-v2 to 0.269 with the correct sentence sitting at reading two or three, and keeping three lifts them to 0.839 and 0.763 while quanpin-words-v1 top5 moves only from 0.940 to 0.938. Shorter readings still keep one, because two-syllable keys are where the runner-ups (倪好, 你号, 你毫 after 你好) push dictionary words off the page, and keeping three there costs words top5 two points.
///
/// They are moved rather than removed. Deleting them threw away the model's later choices, so a reading the model ranked fourth was unreachable even when it was right.
///
/// Only lattice readings are touched. An earlier version of this keyed on "any source that is not a dictionary", which is wrong twice over: a source number says which code produced a candidate, not that two candidates are spellings of one answer, and most of the other sources are plural by design — English words, emoji, kaomoji, quick phrases and AI suggestions all arrive as lists, and that version silently dropped all but one of each.
pub fn runner_up_order<'a, R: OrderRowSource<'a> + ?Sized>(
    scheme: u8,
    rows: &R,
) -> Option<Vec<usize>> {
    runner_up_order_with(scheme, rows, &[])
}

/// [`runner_up_order`],`touch_supported[i]` 为真的纠错行不算整句读法、留在引擎给的座位上。
///
/// 26 键双拼带着触点查出的表里,落点支持的纠错整句是按落点计过价的改正,不是同一串按键的又一种读法;把它当次选读法挪到表尾,它就几乎从不出现在第一页(双拼触点评测里能找到原句的纠错整句有九成排在表尾)。按行而不是按整张表判断:同一张表里还可能有别的纠错行(模糊音替代读法按平面表查出的纠错整句),它们没过落点的闸,照旧沉底。`touch_supported` 与 `rows` 等长,或者为空表示没有这样的行;长度对不上时整列作废。没有触点的表(桌面、实体键盘)照旧,结果逐字节不变。
pub fn runner_up_order_with<'a, R: OrderRowSource<'a> + ?Sized>(
    scheme: u8,
    rows: &R,
    touch_supported: &[bool],
) -> Option<Vec<usize>> {
    // The lattice runs from two syllables (a single syllable is never decoded), so a shorter candidate reached the list some other way and is not a reading of the same sentence. Japanese kana are the case that proves it: あ and ア are both Generated and both one character. Two rather than three because two-syllable keys are where the lattice's runner-up readings otherwise fill the first page ahead of dictionary words: on quanpin-words-v1 this moves two-syllable top5 from 0.883 to 0.924 with top1 unchanged.
    const SENTENCE_SYLLABLES: usize = 2;
    // From this many characters a reading is a sentence rather than a word, and the page keeps `SENTENCE_READINGS` of them.
    const LONG_SENTENCE_CHARACTERS: usize = 3;
    const SENTENCE_READINGS: usize = 3;

    // Korean Hanja rows are not lattice readings, and their table order is the one to keep.
    if !reorders_candidates(scheme) {
        return None;
    }
    let count = rows.len();
    if count < 2 {
        return None;
    }
    let touch_supported = if touch_supported.len() == count {
        touch_supported
    } else {
        &[]
    };
    let lattice = |index: usize, row: &OrderRow<'_>| {
        row.source == LATTICE_SOURCE
            && !(row.corrected && touch_supported.get(index).copied().unwrap_or(false))
    };
    let width = (0..rows.len())
        .map(|index| (index, rows.get(index)))
        .find(|(index, row)| lattice(*index, row))
        .map(|(_, row)| row.text.chars().count())?;
    if width < SENTENCE_SYLLABLES {
        return None;
    }
    let keep = if width >= LONG_SENTENCE_CHARACTERS {
        SENTENCE_READINGS
    } else {
        1
    };
    // Every lattice reading of the full key, in list order. The first stays where it is, the next `keep - 1` are seated right behind it, and the rest go to the back in their existing order. `keep` is at most three, so rescanning the rows costs less than allocating separate reading and membership arrays.
    let is_reading = |index: usize| {
        let row = rows.get(index);
        lattice(index, &row) && row.text.chars().count() == width
    };
    // 先只扫描输出顺序,常见的整句候选已经连续时直接返回,避免为最终排列分配缓冲。
    let mut output = 0;
    let mut identity = true;
    let mut reading_seen = 0;
    for index in 0..count {
        if !is_reading(index) {
            identity &= output == index;
            output += 1;
            continue;
        }
        if reading_seen == 0 {
            identity &= output == index;
            output += 1;
            let mut kept = 1;
            for next in index + 1..count {
                if is_reading(next) {
                    if kept == keep {
                        break;
                    }
                    identity &= output == next;
                    output += 1;
                    kept += 1;
                }
            }
        }
        reading_seen += 1;
    }
    reading_seen = 0;
    for index in 0..count {
        if is_reading(index) {
            if reading_seen >= keep {
                identity &= output == index;
                output += 1;
            }
            reading_seen += 1;
        }
    }
    debug_assert_eq!(output, count);
    if identity {
        return None;
    }

    let mut order = Vec::with_capacity(count);
    let mut reading_seen = 0;
    for index in 0..count {
        if !is_reading(index) {
            order.push(index);
            continue;
        }
        if reading_seen == 0 {
            order.push(index);
            let mut kept = 1;
            for next in index + 1..count {
                if is_reading(next) {
                    if kept == keep {
                        break;
                    }
                    order.push(next);
                    kept += 1;
                }
            }
        }
        reading_seen += 1;
    }
    reading_seen = 0;
    for index in 0..count {
        if is_reading(index) {
            if reading_seen >= keep {
                order.push(index);
            }
            reading_seen += 1;
        }
    }
    debug_assert_eq!(order.len(), count);
    if order.iter().enumerate().all(|(seat, index)| seat == *index) {
        return None;
    }
    Some(order)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn row(text: &str, source: u8) -> OrderRow<'_> {
        OrderRow {
            text,
            source,
            answers_key: true,
            corrected: false,
        }
    }

    #[test]
    fn rerank_context_that_fits_is_handed_over_whole() {
        // Short contexts are what the sentence eval measured, so they must reach the model untouched.
        assert_eq!(rerank_context("你好世界", 64, 10), "你好世界");
        assert_eq!(rerank_context("", 64, 10), "");
    }

    #[test]
    fn rerank_context_holds_still_while_a_candidate_grows() {
        // A long context used to slide by one character per keystroke, which made the reranker rerun its prefix every time.
        let context: String = "今天天气很好我们一起去公园散步".repeat(8);
        let windows: Vec<&str> = (1..=40)
            .map(|longest| rerank_context(&context, 64, longest))
            .collect();
        let mut distinct = windows.clone();
        distinct.dedup();
        assert!(
            distinct.len() <= 64 / RERANK_CONTEXT_STEP + 1,
            "{}",
            distinct.len()
        );
        for (longest, window) in (1..=40).zip(&windows) {
            let count = window.chars().count();
            assert!(count + longest < 64, "longest {longest} kept {count}");
            assert_eq!(count % RERANK_CONTEXT_STEP, 0);
            // Always the most recent text, never the start of it.
            assert!(context.ends_with(window));
        }
    }

    #[test]
    fn rerank_context_cuts_on_a_character_boundary_and_can_empty() {
        let context = "a中b文".repeat(40);
        let window = rerank_context(&context, 64, 20);
        assert_eq!(window.chars().count(), 32);
        assert!(context.ends_with(window));
        // A candidate that fills the window leaves no room, and the answer is an empty context rather than a panic.
        assert_eq!(rerank_context(&context, 64, 70), "");
    }

    #[test]
    fn rerank_context_length_ignores_candidates_beyond_model_budget() {
        let rows = [
            row("甲", LATTICE_SOURCE),
            row("乙", LATTICE_SOURCE),
            row("丙", LATTICE_SOURCE),
            row("丁", LATTICE_SOURCE),
            row("戊", LATTICE_SOURCE),
            row("己", LATTICE_SOURCE),
            row("庚", LATTICE_SOURCE),
            row("辛", LATTICE_SOURCE),
            row("壬", LATTICE_SOURCE),
            row("这是没有进入模型比较集合的长候选", LATTICE_SOURCE),
        ];
        assert_eq!(longest_rerank_candidate(&rows, &|row| row.answers_key), 1);
    }

    #[test]
    fn in_place_order_applies_candidate_permutations() {
        let mut values = vec!["zero", "one", "two", "three", "four"];
        apply_order(&mut values, &[2, 4, 1, 0, 3]);
        assert_eq!(values, vec!["two", "four", "one", "zero", "three"]);
    }

    #[test]
    fn in_place_order_preserves_ownership_and_storage_boundaries() {
        struct Value(usize);
        for count in [0, 1, 127, 128, 129, 256, 8192] {
            let mut order: Vec<usize> = (0..count).collect();
            if count > 1 {
                order[1..].rotate_left(1);
            }
            let mut values: Vec<Value> = (0..count).map(Value).collect();
            let pointer = values.as_ptr();
            let (_, allocations) = crate::ime::personal_rerank::allocations::count(|| {
                apply_order(&mut values, &order);
            });
            assert_eq!(values.as_ptr(), pointer);
            assert!(allocations <= usize::from(count > 128));
            assert_eq!(
                values.iter().map(|value| value.0).collect::<Vec<_>>(),
                order
            );
        }
        let mut values: Vec<usize> = (0..8192).collect();
        let order = values.clone();
        let (_, allocations) = crate::ime::personal_rerank::allocations::count(|| {
            apply_order(&mut values, &order);
        });
        assert_eq!(values, order);
        assert_eq!(allocations, 0);
    }

    #[test]
    fn in_place_order_matches_every_small_permutation() {
        fn visit(order: &mut [usize], start: usize) {
            if start == order.len() {
                let mut values: Vec<usize> = (0..order.len()).collect();
                apply_order(&mut values, order);
                assert_eq!(values, order);
                return;
            }
            for next in start..order.len() {
                order.swap(start, next);
                visit(order, start + 1);
                order.swap(start, next);
            }
        }
        for count in 0..=7 {
            visit(&mut (0..count).collect::<Vec<_>>(), 0);
        }
    }

    #[test]
    #[ignore = "长循环的配对计时,需在空闲主机上显式运行"]
    fn long_candidate_permutations_have_linear_scaling() {
        use std::hint::black_box;
        use std::time::Instant;

        fn elapsed(count: usize) -> u128 {
            let order: Vec<usize> = std::iter::once(0)
                .chain(2..count)
                .chain(std::iter::once(1))
                .collect();
            let mut values: Vec<usize> = (0..count).collect();
            apply_order(&mut values, &order);
            assert_eq!(values, order);
            let begin = Instant::now();
            for _ in 0..32768 / count {
                apply_order(black_box(&mut values), black_box(&order));
            }
            black_box(values);
            begin.elapsed().as_nanos()
        }
        // 两种长度处理相同总行数;交替先后顺序取中位数,预算留出主机噪声。
        let mut small = Vec::new();
        let mut large = Vec::new();
        for round in 0..5 {
            if round % 2 == 0 {
                small.push(elapsed(128));
                large.push(elapsed(8192));
            } else {
                large.push(elapsed(8192));
                small.push(elapsed(128));
            }
        }
        small.sort_unstable();
        large.sort_unstable();
        eprintln!(
            "permutation_equal_rows_small_ns={} large_ns={}",
            small[2], large[2]
        );
        assert!(
            large[2] <= small[2] * 16,
            "同等总行数的长排列成本过高: {large:?} / {small:?}"
        );
    }

    /// Only `CandidateSource::Generated` names alternative readings of one key. Every other source
    /// is plural by design — English words, emoji, kaomoji, quick phrases, AI suggestions — and an
    /// earlier version of this rule kept one of each and dropped the rest.
    #[test]
    fn one_lattice_row_needs_no_temporary_order_allocations() {
        let rows = [row("你好", LATTICE_SOURCE), row("你", 0)];
        let (order, allocations) =
            crate::ime::personal_rerank::allocations::count(|| runner_up_order(0, &rows));
        assert_eq!(order, None);
        assert_eq!(allocations, 0);
    }

    #[test]
    fn short_rerank_texts_need_no_temporary_heap_state() {
        let rows = [row("你好", LATTICE_SOURCE), row("倪好", LATTICE_SOURCE)];
        let (summary, allocations) = crate::ime::personal_rerank::allocations::count(|| {
            with_row_texts(&rows, |texts| (texts.len(), texts[0], texts[1]))
        });
        assert_eq!(summary, (2, "你好", "倪好"));
        assert_eq!(allocations, 0);
    }

    #[test]
    fn static_rerank_inputs_need_no_temporary_heap_state() {
        let rows = [row("甲", LATTICE_SOURCE), row("乙", LATTICE_SOURCE)];
        let scores = [
            Some(StaticEvidence {
                log_prob_milli_nats: -1000,
                characters: 1,
            }),
            Some(StaticEvidence {
                log_prob_milli_nats: -2000,
                characters: 1,
            }),
        ];
        let scored = ScoredOrderRows::new(&rows, &scores);
        let (summary, allocations) = crate::ime::personal_rerank::allocations::count(|| {
            let mut considered = [0; STATIC_RERANK_LIMIT];
            let mut statik = [0.0; STATIC_RERANK_LIMIT];
            let mut subset = [""; STATIC_RERANK_LIMIT];
            let count = fill_static_rerank_inputs(
                &scored,
                |row| row.answers_key,
                &mut considered,
                &mut statik,
                &mut subset,
            );
            (count, considered[0], considered[1], statik[0], subset[1])
        });
        assert_eq!(summary, (Some(2), 0, 1, -1.0, "乙"));
        assert_eq!(allocations, 0);
    }

    #[test]
    fn only_the_lattice_source_is_treated_as_alternative_readings() {
        assert_eq!(LATTICE_SOURCE, 8);
        for plural in [2u8, 3, 4, 5, 6, 7] {
            assert_ne!(LATTICE_SOURCE, plural);
        }
    }

    #[test]
    fn engine_order_is_built_only_when_missing() {
        let mut engine_order = Vec::new();
        ensure_engine_order(&mut engine_order, 3);
        assert_eq!(engine_order, vec![0, 1, 2]);
        // 已经是这一轮的映射时保持原样,不重置成恒等排列。
        engine_order.swap(0, 2);
        ensure_engine_order(&mut engine_order, 3);
        assert_eq!(engine_order, vec![2, 1, 0]);
    }

    #[test]
    fn rotate_to_front_keeps_the_rest_in_order() {
        let mut items = vec!["a", "b", "c", "d"];
        rotate_to_front(&mut items, 2);
        assert_eq!(items, vec!["c", "a", "b", "d"]);
    }

    #[test]
    fn only_korean_keeps_its_table_order() {
        let korean = SchemeType::Korean as u8;
        assert!(!reorders_candidates(korean));
        for ordinal in (0..=u8::MAX).filter(|&ordinal| ordinal != korean) {
            if let Some(scheme) = SchemeType::from_u8(ordinal) {
                assert_eq!(reorders_candidates(ordinal), scheme.holds_phrase_progress());
            }
        }
        // 刷新失败时的占位快照不对应任何方案,照旧允许重排。
        assert!(reorders_candidates(255));
    }

    #[test]
    fn only_shuangpin_corrections_stay_behind() {
        let shuangpin = SchemeType::Shuangpin as u8;
        assert!(!corrections_may_lead(shuangpin));
        for ordinal in (0..=u8::MAX).filter(|&ordinal| ordinal != shuangpin) {
            assert!(corrections_may_lead(ordinal), "{ordinal}");
        }
    }

    #[test]
    fn long_sentences_keep_three_readings_after_the_first() {
        // 第一条整句留在原位,其后两条紧跟着,其余整句挪到末尾;非整句来源保持相对顺序。
        let rows = [
            row("今天天气", LATTICE_SOURCE),
            row("今天", 0),
            row("金天天气", LATTICE_SOURCE),
            row("今天添气", LATTICE_SOURCE),
            row("今添天气", LATTICE_SOURCE),
            row("金", 0),
        ];
        assert_eq!(runner_up_order(0, &rows), Some(vec![0, 2, 3, 1, 5, 4]));
    }

    #[test]
    fn two_character_readings_keep_only_the_first() {
        let rows = [
            row("你好", LATTICE_SOURCE),
            row("倪好", LATTICE_SOURCE),
            row("你", 0),
        ];
        assert_eq!(runner_up_order(0, &rows), Some(vec![0, 2, 1]));
    }

    /// 带触点查出的双拼表里,落点支持的纠错整句不算整句读法:前三条字面读法照旧留在前面、其余挪到表尾,纠错行留在引擎给的座位上。没有触点时它和别的读法一样被挪走。
    #[test]
    fn touch_priced_corrections_keep_their_engine_seat() {
        let shuangpin = SchemeType::Shuangpin as u8;
        let corrected = OrderRow {
            corrected: true,
            ..row("没关系", LATTICE_SOURCE)
        };
        let rows = [
            row("没跟系", LATTICE_SOURCE),
            row("美跟系", LATTICE_SOURCE),
            row("没根系", LATTICE_SOURCE),
            row("美根系", LATTICE_SOURCE),
            corrected,
            row("没跟", 0),
            row("没", 0),
        ];
        let supported = [false, false, false, false, true, false, false];
        assert_eq!(
            runner_up_order_with(shuangpin, &rows, &supported),
            Some(vec![0, 1, 2, 4, 5, 6, 3])
        );
        assert_eq!(
            runner_up_order_with(shuangpin, &rows, &[]),
            Some(vec![0, 1, 2, 5, 6, 3, 4])
        );
        assert_eq!(
            runner_up_order_with(shuangpin, &rows, &[]),
            runner_up_order(shuangpin, &rows)
        );
        // 长度对不上的列整列作废,不会错把别的行当成落点支持的。
        assert_eq!(
            runner_up_order_with(shuangpin, &rows, &supported[..6]),
            runner_up_order(shuangpin, &rows)
        );
        // 只有纠错行才看这一列:字面读法即使被标上也照旧算读法。
        let mut literal = supported;
        literal[3] = true;
        assert_eq!(
            runner_up_order_with(shuangpin, &rows, &literal),
            Some(vec![0, 1, 2, 4, 5, 6, 3])
        );
        // 纠错行挤在前三条读法里时,没有触点它占掉一个名额;有触点时让出名额,第三条字面读法坐到前两条后面,纠错行跟在它后面。
        let crowded = [
            row("没跟系", LATTICE_SOURCE),
            row("美跟系", LATTICE_SOURCE),
            corrected,
            row("没根系", LATTICE_SOURCE),
            row("没", 0),
        ];
        assert_eq!(
            runner_up_order_with(shuangpin, &crowded, &[]),
            Some(vec![0, 1, 2, 4, 3])
        );
        assert_eq!(
            runner_up_order_with(shuangpin, &crowded, &[false, false, true, false, false]),
            Some(vec![0, 1, 3, 2, 4])
        );
    }

    /// 守卫的另一半(#10018 PR3):引擎按首选规则把双拼纠错整句放在第一位时(它同时是落点支持的那一行),重排器不跑模型、不把它换下去,次选读法的后移也不动它,字面整句留在第二位、前三条字面读法照旧在它后面。全拼的纠错行本来就允许领先,同样一张表照常交给模型比较。
    #[test]
    fn a_shuangpin_correction_the_engine_put_first_stays_first() {
        let shuangpin = SchemeType::Shuangpin as u8;
        let rows = [
            OrderRow {
                corrected: true,
                ..row("没关系", LATTICE_SOURCE)
            },
            row("没跟系", LATTICE_SOURCE),
            row("美跟系", LATTICE_SOURCE),
            row("没根系", LATTICE_SOURCE),
            row("美根系", LATTICE_SOURCE),
            row("没跟", 0),
            row("没", 0),
        ];
        assert!(!rerank_needs_model(shuangpin, false, &rows));
        assert!(rerank_needs_model(SchemeType::Quanpin as u8, false, &rows));
        let supported = [true, false, false, false, false, false, false];
        let order = runner_up_order_with(shuangpin, &rows, &supported).unwrap();
        assert_eq!(order, vec![0, 1, 2, 3, 5, 6, 4]);
        assert_eq!(order.iter().position(|&row| row == 1), Some(1));
    }

    /// 同一张表里有两条纠错整句(模糊音替代读法按平面表查出的那条没过落点的闸):只有落点支持的那条留在座位上,另一条照旧沉底。
    #[test]
    fn only_the_touch_supported_correction_keeps_its_seat() {
        let shuangpin = SchemeType::Shuangpin as u8;
        let corrected = |text: &'static str| OrderRow {
            corrected: true,
            ..row(text, LATTICE_SOURCE)
        };
        let rows = [
            row("没跟系", LATTICE_SOURCE),
            row("美跟系", LATTICE_SOURCE),
            row("没根系", LATTICE_SOURCE),
            row("美根系", LATTICE_SOURCE),
            corrected("没关系"),
            corrected("美关系"),
            row("没跟", 0),
            row("没", 0),
        ];
        let supported = [false, false, false, false, true, false, false, false];
        assert_eq!(
            runner_up_order_with(shuangpin, &rows, &supported),
            Some(vec![0, 1, 2, 4, 6, 7, 3, 5])
        );
    }

    #[test]
    fn runner_up_order_leaves_lists_it_has_no_say_over() {
        let korean = SchemeType::Korean as u8;
        let rows = [
            row("你好", LATTICE_SOURCE),
            row("倪好", LATTICE_SOURCE),
            row("你", 0),
        ];
        assert_eq!(runner_up_order(korean, &rows), None);
        // 少于两行、没有整句读法、整句只有一个字(假名)、或排列不变时都返回 None。
        assert_eq!(runner_up_order(0, &rows[..1]), None);
        assert_eq!(runner_up_order(0, &[row("你", 0), row("尼", 0)]), None);
        assert_eq!(
            runner_up_order(0, &[row("あ", LATTICE_SOURCE), row("ア", LATTICE_SOURCE)]),
            None
        );
        assert_eq!(
            runner_up_order(0, &[row("你好", LATTICE_SOURCE), row("你", 0)]),
            None
        );
    }
}