msime-engine 0.1.1

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
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
//! `QuanpinDictionary` (`R/quanpin/quanpin_dictionary.cpp`, quanpin.md §4, §6, §7.5-§7.7, §9.9, §11, §13, §14): the query pipeline and its caches, plus the canonical-pinyin phrase writer learning uses (pins, removals and frequency learning go through `user_dictionary` directly, see `ime::registry`).

use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::hash_map::DefaultHasher;
use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::ops::{Deref, DerefMut};
use std::sync::{Arc, RwLockReadGuard};
use std::time::Duration;

use crate::assets::{MAIN_DICTIONARY, NEURAL_MODEL_KEYBOARD, USER_JOURNAL};
use crate::cache::FifoCache;
use crate::dictionary::pinyin::{PinyinDatabase, INSERTED_WEIGHT};
use crate::dictionary::DictRow;
use crate::error::{EngineError, Result};
use crate::ime::online_batch::{
    replace_cached_online_candidate_batch, replace_online_candidate_batch,
};
use crate::lattice::decode::{decode_pinned, BigramMemo, PinnedSpan, SentenceNgramTables};
use crate::lattice::merge::{
    decode_lattice, decode_lattice_borrowed, merge_decoded, merge_decoded_borrowed,
    whole_sentence_insert_position, DecodedSentences,
};
use crate::lattice::neural::{
    shared_sentence_model, NeuralReranker, CONTEXT_CHARACTERS, MAX_RERANK_PATHS,
};
use crate::lattice::personal::{PersonalNgram, PersonalTransition};
use crate::lattice::{SentencePath, TypoEdgeSource};
use crate::paths::RuntimePaths;
use crate::pinyin::fuzzy::{fuzzy_segmentations, FUZZY_SEGMENTATION_LIMIT};
use crate::pinyin::graph::{
    build_syllable_graph, enumerate_complete_segmentations, SYLLABLE_GRAPH_PATH_LIMIT,
};
use crate::pinyin::jianpin::{split_initial_token_paths, QuerySource};
use crate::pinyin::segment::{
    cut_pinyin_by_mode, is_complete_pinyin_input, join_segments, split_segments, CutMode,
};
use crate::pinyin::syllables::{
    has_only_complete_pinyin_segments, normalize_umlaut_aliases, sparse_pinyin_fallback_segments,
};
use crate::text::{count_han_chars, last_characters};
use crate::time::Instant;
use crate::types::{
    autocorrect_type, CandidateSource, FirstPageRows, FuzzyPinyinOptions, PersonalDictionaryKind,
    SentenceAssociationOptions, WordItem,
};
use crate::user_dictionary::journal::record_user_insert;
use crate::user_dictionary::ngram_store::PersonalNgramStore;
use crate::user_dictionary::typo_profile::PersonalTypoProfile;

use super::series::{
    append_unique_words, fold_reading, has_corrected_cut, lead_literal_abbreviation_phrases,
    mark_autocorrect_candidates, merge_alternative_rows, merge_alternative_segmentations,
    merge_series_by_coverage, resolve_series_query, series_cache_key, SeriesResolution,
    ALTERNATIVE_SEGMENTATION_CANDIDATE_LIMIT, AUTOCORRECT_CUT_KBEST, LONGER_PHRASE_EXTRA_SYLLABLES,
    LONGER_PHRASE_LIMIT, MAX_SYLLABLES_FOR_MULTIPLE_SEGMENTATIONS,
};
use super::typo_edges::collect_typo_edges;

#[cfg(test)]
mod marking_buffer_tests;

mod page_copy;
mod path_page;

use path_page::PathPage;

pub const CACHE_CAPACITY: usize = 128;
pub const TYPO_SPAN_CACHE_CAPACITY: usize = 512;
/// 词网格的跨度查询结果按跨度缓存的条数。九键每按一键要对几十条切分各解一次词网格,这些切分大量共用同样的跨度,下一键又会重复上一键的大部分跨度,所以容量按一次长输入里出现的不同跨度数来定。
pub const LATTICE_SPAN_CACHE_CAPACITY: usize = 4096;
/// 按切分缓存的词典原始行(`query_segments_keyed_flat`、单字母的 `query_initial`、`query_longer_phrases` 和模糊前缀的批量查询)每种的条数。`cache` 只有 128 条,九键一键的几十条切分会把它整个挤掉;这一层只存与查询方式无关的词典行,命中时与直接查库完全一样。
pub const ROW_CACHE_CAPACITY: usize = 512;
/// 按音节缓存的整句解码结果条数。九键每按一个数字要对二十条上下的切分各解一次整句,其中约三分之一是同一次刷新或上一个数字已经解过的音节串(同一条备选切分挂在好几条九键路径下);一个长词打下来不同的音节串有两三百条。每条只存解码出的几条句子,内存很小。
pub const SENTENCE_DECODE_CACHE_CAPACITY: usize = 512;
/// `RowCacheBatch` 一段读事务最长持续的时间。词典的忙等待上限 `BUSY_TIMEOUT` 是 250 ms,这一段要远小于它,别的连接提交时才不会因为一次长刷新等到超时。
const ROW_CACHE_BATCH_SPAN: Duration = Duration::from_millis(20);
/// A single-letter query returns at most this many rows until `expand_initial_candidates`.
pub const INITIAL_CANDIDATE_LIMIT: usize = 24;
pub const SPARSE_FALLBACK_THRESHOLD: usize = 8;
/// Twice the series cache's capacity: more keys than that cannot all still be cached.
pub const PERSONAL_SCORED_KEY_LIMIT: usize = 256;
/// The reference's `INT_MAX` row limit, kept as a finite value so every `LIMIT` bind stays in range.
const UNLIMITED_ROWS: usize = i32::MAX as usize;
/// The budget of fuzzy paths one `fuzzy_candidates` call may query, shared by every prefix length (QD:1691).
const FUZZY_PATH_BUDGET: usize = 128;
const FUZZY_ROW_LIMIT: usize = 128;
// k-best 纠错切分去掉主切分后最多 8 条,再加上完整音节图最多 32 条。
const ALTERNATIVE_SEGMENTATION_SEEN_CAPACITY: usize =
    AUTOCORRECT_CUT_KBEST - 1 + SYLLABLE_GRAPH_PATH_LIMIT;
/// The masks whose lists depend on the typo profile and carry typo sentences.
const TYPO_EDGE_TYPES: u32 = autocorrect_type::TRANSPOSITION
    | autocorrect_type::NEIGHBOR
    | autocorrect_type::MISSING_OR_EXTRA;

#[derive(Clone)]
struct CachedResolution {
    key: String,
    resolution: SeriesResolution,
}

#[derive(Clone)]
struct CachedFuzzyCandidates {
    rules: u32,
    segmentation: String,
    candidates: Vec<WordItem>,
}

#[derive(Clone)]
struct CachedFuzzyRows {
    rules: u32,
    limit: usize,
    typed: String,
    rows: Vec<DictRow>,
}

#[derive(Clone)]
struct CachedDecode {
    /// 解码时个人模型的版本;模型一变,条目作废。
    personal_version: u64,
    decoded: DecodedSentences,
}

#[derive(Clone)]
struct CachedLatticeSpan {
    span_limit: usize,
    span: String,
    rows: Vec<DictRow>,
}

enum ColdRows {
    Transfer,
    Borrow,
}

enum ProjectedRows {
    Selected(Vec<WordItem>),
    /// 只供借用冷页使用,谓词已全部接受,尚未复制字段。
    All,
}

impl From<Vec<WordItem>> for ProjectedRows {
    fn from(rows: Vec<WordItem>) -> Self {
        Self::Selected(rows)
    }
}

impl ProjectedRows {
    fn selected(self) -> Vec<WordItem> {
        match self {
            Self::Selected(rows) => rows,
            Self::All => unreachable!("只有借用冷页可以延后复制"),
        }
    }
}

/// The reference answered a failed write with `ERROR_CODE` and no message; callers map any failure to their own diagnostic.
const DICTIONARY_UNAVAILABLE: &str = "Pinyin dictionary is unavailable";
const ENTRY_REJECTED: &str = "Pinyin does not spell the word one syllable per character";

/// `QuanpinDictionary::row_cache_batch` 开的批,通过它调用词典的方法;丢弃时(包括 panic 展开时)提交读事务、恢复逐条对版本。
pub struct RowCacheBatch<'a> {
    dictionary: &'a mut QuanpinDictionary,
}

impl Deref for RowCacheBatch<'_> {
    type Target = QuanpinDictionary;

    fn deref(&self) -> &QuanpinDictionary {
        self.dictionary
    }
}

impl DerefMut for RowCacheBatch<'_> {
    fn deref_mut(&mut self) -> &mut QuanpinDictionary {
        self.dictionary
    }
}

impl Drop for RowCacheBatch<'_> {
    fn drop(&mut self) {
        self.dictionary.row_cache_batch = None;
        self.dictionary.database.end_read();
    }
}

pub struct QuanpinDictionary {
    database: PinyinDatabase,
    paths: RuntimePaths,
    sentence_tables: SentenceNgramTables,
    typo_profile: Arc<PersonalTypoProfile>,
    /// The profile generation the cached lists were built under.
    typo_generation: u64,
    personal: Arc<PersonalNgramStore>,
    /// The personal model version the personal-scored lists were built under.
    personal_version: u64,
    /// Series keys whose lists were scored with the personal model, dropped when its version moves.
    personal_scored_keys: HashSet<String>,
    /// 每条切分的词典页,完整展开页保留原来的拥有型表示。
    cache: FifoCache<String, PathPage>,
    /// Whole answers per series key, including online rows.
    series_cache: FifoCache<String, Vec<WordItem>>,
    segmentation_cache: FifoCache<String, Vec<String>>,
    /// Survives `reset_cache`: a resolution depends only on the input and the static correction tables, never on dictionary rows.
    resolution_cache: FifoCache<u64, CachedResolution>,
    /// Fuzzy rows are dictionary-dependent, but their composite key is indexed by hash so a cache hit does not allocate a formatted string.
    fuzzy_cache: FifoCache<u64, CachedFuzzyCandidates>,
    /// Rows of typo-variant span keys, empty answers included. Dictionary rows only, so it is cleared with the other caches.
    typo_span_cache: FifoCache<String, Vec<DictRow>>,
    /// 词网格每个跨度的 `query_lattice_span` 结果,空结果也存。哈希只做索引,命中后再核对上限和原样跨度,避免每次命中都拼接键字符串。
    lattice_span_cache: FifoCache<u64, CachedLatticeSpan>,
    /// 每条切分的 `query_segments_keyed_flat` 结果,键是切分本身;单字母的 `query_initial` 结果也存在这里,键前加 `\u{1}`。
    segment_row_cache: FifoCache<String, Vec<DictRow>>,
    /// 每条切分的 `query_longer_phrases` 结果,键是切分本身。
    longer_row_cache: FifoCache<String, Vec<DictRow>>,
    /// `fuzzy_candidates` 里每个前缀那一次批量查询的结果,键是模糊规则、路径上限和前缀切分;三者定下来,查的路径和返回的行就定了。
    fuzzy_row_cache: FifoCache<u64, CachedFuzzyRows>,
    /// 上面四个行缓存填入时的 `PRAGMA data_version`。它们只存词典行,随其他缓存一起清空;此外每次真正计算一个查询前都对一次版本,别的连接写过词典就先清掉,所以命中的行永远和此刻直接查库一样,不会像 `cache` 那样等到下次 `reset_cache_if_database_changed` 才更新。
    row_cache_version: Option<i64>,
    /// `RowCacheBatch` 存活期间为当前这段读事务开始的时刻:版本在这段开始时对过,段内的查询不再逐条对;一段超过 `ROW_CACHE_BATCH_SPAN` 就提交、重开并重新对版本。
    row_cache_batch: Option<Instant>,
    /// 词网格解码查二元分的备忘,跨查询保留;表不变它就一直有效,见 `BigramMemo`。
    bigram_memo: RefCell<BigramMemo>,
    /// 整句解码结果,键见 `sentence_decode_key`。解码只取决于音节、词典行、n 元表、个人模型和整句选项:词典行变了随行缓存清空,个人模型按条目记的版本核对,整句选项和纠错资料变了在各自的 setter 里清空。
    sentence_decode_cache: FifoCache<String, CachedDecode>,
    rerankers: Vec<NeuralReranker>,
    /// octagram `.gram` 语法模型;路径由 `set_sentence_association` 给定、经
    /// `GramDb::shared` 进程内共享,缺失或非法时为 `None` 退回无搭配打分。
    collocation_db: Option<Arc<crate::lattice::octagram::GramDb>>,
    sentence_alternatives: bool,
    sentence_association: SentenceAssociationOptions,
    /// 首页放得下几行,决定全长组的补全词是按权重并进来还是接在整组末尾;来自宿主的每页候选数(`QueryRequest::first_page_rows`)。存成 `FirstPageRows` 是为了让 0 在这里没有入口,全拼和九键的「0 当 1」只在 `FirstPageRows::new` 做一次。
    first_page_rows: FirstPageRows,
    rescoring_context: String,
    /// The reading and mask of the query being answered; marking and the typo edges read them within that query.
    pinyin_segmentation: String,
    /// Every non-primary correction cut, joined; marking compares against them on every query, cached or not.
    alternative_segmentations: Vec<String>,
    current_autocorrect_types: u32,
}

impl QuanpinDictionary {
    /// Opens the generation's `msime-pinyin.db`, loads the n-gram tables, the personal context store and the typo profile off the keystroke path, and warms the statement cache (QD:231-268).
    pub fn new(paths: &RuntimePaths) -> Self {
        let database = PinyinDatabase::open(&paths.dictionary(MAIN_DICTIONARY));
        database.warm_up();
        // 构造时预热并保留表;共享缓存淘汰不能让按键查询重新映射和检查整张表。
        let sentence_tables = SentenceNgramTables::load(paths);
        let journal = paths.user(USER_JOURNAL);
        let personal = PersonalNgramStore::for_journal(&journal);
        drop(personal.model());
        let personal_version = personal.version();
        let typo_profile = PersonalTypoProfile::shared(&journal);
        let typo_generation = typo_profile.generation();
        let mut dictionary = Self {
            database,
            paths: paths.clone(),
            sentence_tables,
            typo_profile,
            typo_generation,
            personal,
            personal_version,
            personal_scored_keys: HashSet::new(),
            cache: FifoCache::new(CACHE_CAPACITY),
            series_cache: FifoCache::new(CACHE_CAPACITY),
            segmentation_cache: FifoCache::new(CACHE_CAPACITY),
            resolution_cache: FifoCache::new(CACHE_CAPACITY),
            fuzzy_cache: FifoCache::new(CACHE_CAPACITY),
            typo_span_cache: FifoCache::new(TYPO_SPAN_CACHE_CAPACITY),
            lattice_span_cache: FifoCache::new(LATTICE_SPAN_CACHE_CAPACITY),
            segment_row_cache: FifoCache::new(ROW_CACHE_CAPACITY),
            longer_row_cache: FifoCache::new(ROW_CACHE_CAPACITY),
            fuzzy_row_cache: FifoCache::new(ROW_CACHE_CAPACITY),
            row_cache_version: None,
            row_cache_batch: None,
            bigram_memo: RefCell::default(),
            sentence_decode_cache: FifoCache::new(SENTENCE_DECODE_CACHE_CAPACITY),
            rerankers: Vec::new(),
            collocation_db: None,
            sentence_alternatives: false,
            sentence_association: SentenceAssociationOptions::default(),
            first_page_rows: FirstPageRows::default(),
            rescoring_context: String::new(),
            pinyin_segmentation: String::new(),
            alternative_segmentations: Vec::new(),
            current_autocorrect_types: 0,
        };
        dictionary.reset_cache_if_database_changed();
        dictionary
    }

    /// `query_exact`, the fuzzy merge and letter-count sort, then autocorrect marking (QD:1711-1735). `segmentation` may be empty.
    pub fn query(
        &mut self,
        raw: &str,
        segmentation: &str,
        autocorrect_types: u32,
        fuzzy: FuzzyPinyinOptions,
    ) -> Vec<WordItem> {
        let mut result = self.query_exact(raw, segmentation, autocorrect_types);
        if fuzzy.rules != 0 && !raw.is_empty() {
            // Fuzzy rows are merged here, not in `query_exact`, so the ordinary cache slots stay free of preference-specific rows. The split is of the request's own segmentation, not the normalised one, so a typed `lue` expands under `lue` (quanpin.md §6).
            let normalized_segments = if segmentation.is_empty() {
                self.resolve_segments(raw, segmentation)
            } else {
                Vec::new()
            };
            let typed = fuzzy_segmentation(segmentation, &normalized_segments);
            let rows = self.fuzzy_candidates(typed.as_ref(), fuzzy);
            // 同词的字面前缀不能遮住覆盖更多输入的模糊整词。
            merge_series_by_coverage(&mut result, rows);
            yield_stand_in_sentence_to_fuzzy_rows(&mut result);
            // 按字母数重排会把多一个字母的删字纠错(shng → shang)排回字面简拼整词前面,哪怕模糊音一行也没查到;纠错在作用时把 `query_exact` 定下的先后和保座再做一遍(#9903)。
            if has_corrected_cut(
                raw,
                &self.pinyin_segmentation,
                &self.alternative_segmentations,
            ) {
                lead_literal_abbreviation_phrases(&mut result, raw, self.abbreviation_kept_seat());
            }
        }
        // Marking runs after every change, including the fuzzy merge, so every row the caller sees carries its mark.
        mark_autocorrect_candidates(
            &mut result,
            raw,
            &self.pinyin_segmentation,
            &self.alternative_segmentations,
        );
        result
    }

    /// 九键同步借用完整行筛选;只有核对失效后的系列热页允许提前排名。
    pub(crate) fn query_filtered_rows(
        &mut self,
        raw: &str,
        segmentation: &str,
        fuzzy: FuzzyPinyinOptions,
        mut keep: impl FnMut(&WordItem, bool) -> bool,
    ) -> Vec<WordItem> {
        if fuzzy.rules != 0 {
            // 模糊合并的去重和站位必须先完成,避免被筛掉的词让另一条同文行复活。
            let mut rows = self.query(raw, segmentation, 0, fuzzy);
            rows.retain(|row| keep(row, false));
            return rows;
        }
        let mut result =
            self.query_exact_projected(raw, segmentation, 0, ColdRows::Borrow, |rows, cached| {
                match rows {
                    Cow::Owned(mut rows) => {
                        rows.retain(|row| keep(row, cached));
                        ProjectedRows::Selected(rows)
                    }
                    Cow::Borrowed(rows) => {
                        if !cached {
                            // 谓词全部成功前不淘汰缓存;全留时留给外层复用旧页复制。
                            let Some(rejected) = rows.iter().position(|row| !keep(row, false))
                            else {
                                return ProjectedRows::All;
                            };
                            let mut result = Vec::with_capacity(rows.len());
                            result.extend_from_slice(&rows[..rejected]);
                            result.extend(
                                rows[rejected + 1..]
                                    .iter()
                                    .filter(|row| keep(row, false))
                                    .cloned(),
                            );
                            return ProjectedRows::Selected(result);
                        }
                        // 容器预留完整行数,只跳过拒绝行的字段复制。
                        let mut result = Vec::with_capacity(rows.len());
                        result.extend(rows.iter().filter(|row| keep(row, cached)).cloned());
                        ProjectedRows::Selected(result)
                    }
                }
            });
        mark_autocorrect_candidates(
            &mut result,
            raw,
            &self.pinyin_segmentation,
            &self.alternative_segmentations,
        );
        result
    }

    /// 旧筛选接口只供冻结刷新与既有分配对照使用。
    #[cfg(test)]
    pub(crate) fn query_filtered(
        &mut self,
        raw: &str,
        segmentation: &str,
        fuzzy: FuzzyPinyinOptions,
        mut keep: impl FnMut(&str, &str, &str, bool) -> bool,
    ) -> Vec<WordItem> {
        self.query_filtered_rows(raw, segmentation, fuzzy, |row, _| {
            keep(&row.word, &row.pinyin, &row.canonical_pinyin, row.fuzzy)
        })
    }

    /// 个人上下文模型的只读视图(与同一份用户日志上的输入会话共用)。九键查一次简拼时取一次,按 `PersonalNgram::word_count` 把用户用过的简拼词排到前面(#6185);拿着它时不要记录新的使用,记录要写锁。
    pub fn personal_model(&self) -> RwLockReadGuard<'_, PersonalNgram> {
        self.personal.model()
    }

    /// 把选中的词记进个人上下文模型,26 键也写进同一份。九键选词时调用(#6185);只开放记录这一个写操作,不交出整个存储。
    pub fn record_personal_use(&self, transitions: &[PersonalTransition]) -> Result<()> {
        self.personal.record(transitions)
    }

    /// 简拼 `codes`(同样长度,一个字母一个音节)对应的词条,按权重从高到低最多 `limit` 行,见 `PinyinDatabase::query_jianpin_codes`。`pinyin` 和 `canonical_pinyin` 都是词条的全拼键,用户自己加的词(学习写进词库的整句、自造词)也在里面。
    pub fn query_jianpin_codes(&self, codes: &[String], limit: usize) -> Vec<WordItem> {
        initial_items(self.database.query_jianpin_codes(codes, limit))
    }

    /// 每张首字母表各取前 `table_limit` 行、合起来不再截断的简拼查询,见 `PinyinDatabase::query_jianpin_codes_per_table`。
    pub fn query_jianpin_codes_per_table(
        &self,
        codes: &[String],
        table_limit: usize,
    ) -> Vec<WordItem> {
        initial_items(
            self.database
                .query_jianpin_codes_per_table(codes, table_limit),
        )
    }

    /// 首字母的 24 行候选展开为全部行(QD:506-558),同时更新行缓存与产生该列表的查询槽。
    pub fn expand_initial_candidates(
        &mut self,
        raw: &str,
        segmentation: &str,
        autocorrect_types: u32,
        code: &str,
        candidates: &mut Vec<WordItem>,
    ) -> bool {
        if code.len() != 1 {
            return false;
        }
        let is_limited_initial =
            |item: &WordItem| item.source == CandidateSource::Database && item.pinyin == code;
        let limited_count = candidates
            .iter()
            .filter(|item| is_limited_initial(item))
            .count();
        // Exactly the cap: fewer rows means the initial has nothing more to give.
        if limited_count != INITIAL_CANDIDATE_LIMIT {
            return false;
        }
        let expanded = matched_items(self.database.query_initial(code, UNLIMITED_ROWS), code);
        if expanded.len() <= limited_count {
            return false;
        }
        let first_limited = candidates
            .iter()
            .position(is_limited_initial)
            .expect("limited_count checked above");
        candidates.reserve_exact(expanded.len() - limited_count);
        let mut removed = 0;
        candidates.retain(|item| {
            if is_limited_initial(item) {
                removed += 1;
                false
            } else {
                true
            }
        });
        debug_assert_eq!(removed, limited_count);
        candidates.splice(first_limited..first_limited, expanded.iter().cloned());
        self.cache.insert(code.to_string(), expanded.into());
        // 按参考规则写入不带 `C:` 的槽(QD:554):单字母不纠错。根据调用方查询重算键,避免第二次光标前缀查询把整段输入的展开列表写入自己的槽。
        let cache_key =
            self.project_resolution(raw, segmentation, autocorrect_types, |resolution| {
                series_cache_key(raw, &resolution.segmentation, autocorrect_types)
            });
        let key = self.series_slot(&cache_key);
        self.series_cache.insert(key, candidates.clone());
        true
    }

    /// 切分的模糊候选按规则与读法缓存(QD:1680-1709),双拼也复用该路径。
    pub fn fuzzy_candidates(
        &mut self,
        segmentation: &str,
        options: FuzzyPinyinOptions,
    ) -> Vec<WordItem> {
        if options.rules == 0 || !self.database.is_open() {
            return Vec::new();
        }
        self.reset_cache_if_database_changed();
        let hash = fuzzy_cache_hash(options.rules, segmentation);
        if let Some(cached) = self.fuzzy_cache.get_ref(&hash) {
            if fuzzy_cache_key_matches(cached, options.rules, segmentation) {
                return cached.candidates.clone();
            }
        }
        self.validate_row_caches();
        let segments: Vec<&str> = if segmentation.is_empty() {
            Vec::new()
        } else {
            let count = segmentation.bytes().filter(|&byte| byte == b'\'').count() + 1;
            let mut segments = Vec::with_capacity(count);
            segments.extend(segmentation.split('\''));
            segments
        };
        let mut result = Vec::new();
        let mut budget = FUZZY_PATH_BUDGET;
        let mut typed = segmentation;
        for count in (1..=segments.len()).rev() {
            if budget <= 1 {
                break;
            }
            if count < segments.len() {
                typed = typed.rsplit_once('\'').map_or("", |(prefix, _)| prefix);
            }
            let prefix = &segments[..count];
            let limit = FUZZY_SEGMENTATION_LIMIT.min(budget);
            // 路径照样算出来(纯计算,不查库),预算照旧按路径数扣;只有查库这一步走缓存。
            let paths = fuzzy_segmentations(prefix, options, limit);
            if paths.is_empty() {
                continue;
            }
            budget -= paths.len();
            append_cached_fuzzy_rows(
                &self.database,
                &mut self.fuzzy_row_cache,
                &paths,
                options.rules,
                limit,
                typed,
                &mut result,
            );
        }
        self.fuzzy_cache.insert(
            hash,
            CachedFuzzyCandidates {
                rules: options.rules,
                segmentation: segmentation.to_owned(),
                candidates: result.clone(),
            },
        );
        result
    }

    /// `replace_online_candidate_batch` on the series cache slot of `(raw, segmentation, autocorrect_types)` (QD:1737-1773).
    pub fn insert_online_words(
        &mut self,
        raw: &str,
        segmentation: &str,
        autocorrect_types: u32,
        words: &[String],
        source: CandidateSource,
    ) -> bool {
        if raw.is_empty() || words.is_empty() {
            return false;
        }
        // 按查询原规则重算键,使候选写入下次刷新读取的槽。
        let cache_key =
            self.project_resolution(raw, segmentation, autocorrect_types, |resolution| {
                match resolution {
                    Cow::Borrowed(resolution) => resolution.cache_key.clone(),
                    Cow::Owned(resolution) => resolution.cache_key,
                }
            });
        let key = self.series_slot(&cache_key);
        if let Some(list) = self.series_cache.get_mut(&key) {
            return replace_cached_online_candidate_batch(list, raw, words, source);
        }
        let mut list = Vec::new();
        if !replace_online_candidate_batch(&mut list, raw, words, source) {
            return false;
        }
        self.series_cache.insert(key, list);
        true
    }

    /// Remove one provider's rows from every cached answer while retaining the other provider and dictionary rows.
    pub fn clear_online_candidates(&mut self, source: CandidateSource) {
        self.series_cache.retain_mut(|_, rows| {
            rows.retain(|item| item.source != source);
            !rows.is_empty()
        });
    }

    pub fn find_candidate(&self, key: &str, value: &str) -> Option<WordItem> {
        self.database
            .find_weight(key, value)
            .map(|weight| WordItem::new(key, value, weight, CandidateSource::Database, key))
    }

    pub fn knows_han_char(&self, han: &str) -> bool {
        self.database.han_char_exists(han)
    }

    /// `keys`(以 `'` 连接的完整音节)各自最好那一行的权重;没有行的键不出现。滑行输入用它给一笔可能拼出的串排序。
    pub fn best_weights(&self, keys: &[String]) -> HashMap<String, i64> {
        self.database
            .query_exact_keys_per_key(keys, 1)
            .into_iter()
            .filter_map(|(key, rows)| rows.first().map(|row| (key, row.weight)))
            .collect()
    }

    /// 整句改字的候选:读音恰为 `span`(完整音节)的词条,按权重从高到低,至多 `limit` 条。
    pub fn conversion_rows(&mut self, span: &[String], limit: usize) -> Vec<DictRow> {
        self.validate_row_caches();
        cached_lattice_span(&self.database, &mut self.lattice_span_cache, span, limit)
    }

    /// 整句改字的重新转换:与整句候选同一套词网格、n 元表和个人模型,钉住的段原样保留(`decode_pinned`)。
    pub fn convert_pinned(&mut self, syllables: &[String], pins: &[PinnedSpan]) -> Vec<PinnedSpan> {
        self.validate_row_caches();
        let personal = Arc::clone(&self.personal);
        let model = personal.model();
        let mut options = self.sentence_tables.options(false);
        options.personal = Some(&*model);
        options.bigram_memo = Some(&self.bigram_memo);
        let span_limit = options.span_limit;
        let database = &self.database;
        let lattice_spans = &mut self.lattice_span_cache;
        let mut lookup =
            |span: &[String]| cached_lattice_span(database, lattice_spans, span, span_limit);
        decode_pinned(syllables, &mut lookup, &options, pins)
    }

    /// Umlaut-normalised canonical key, one complete syllable per Han character, no re-cut; an existing row is OK without a journal write (QD:1215-1268).
    pub fn create_word_from_canonical_pinyin(&mut self, pinyin: &str, word: &str) -> Result<()> {
        // Online rows arrive with the typed spelling; they are stored under the nve/lve/ju keys queries resolve to.
        let mut segments = split_segments(pinyin);
        normalize_umlaut_aliases(&mut segments);
        // The caller gave an explicit segmentation, and these checks already prove one complete syllable per character. Validating through a re-cut would erase the boundaries and cut qi'e'huan as qie'huan, rejecting a valid phrase.
        if segments.is_empty()
            || segments.len() != count_han_chars(word)
            || segments
                .iter()
                .any(|segment| segment.is_empty() || !is_complete_pinyin_input(segment))
        {
            return Err(EngineError::invalid(ENTRY_REJECTED));
        }
        self.insert_new_word(&join_segments(&segments), word)
    }

    /// A change clears the row and series caches.
    pub fn set_sentence_alternatives(&mut self, enabled: bool) {
        if self.sentence_alternatives == enabled {
            return;
        }
        self.sentence_alternatives = enabled;
        // The cached lists were assembled under the previous answer.
        self.cache.clear();
        self.series_cache.clear();
        self.sentence_decode_cache.clear();
    }

    /// 首页行数只决定补全词是按权重并进来还是接在整组末尾;改了就清掉按旧门槛组装的整条答案(照 `set_sentence_alternatives`)。`cache` 存的是按切分查出来的词库行,补全词是查完它才并进来的,所以这里动不了它,清掉只会白白丢掉 128 行、再查一次 SQLite。
    pub fn set_first_page_rows(&mut self, rows: FirstPageRows) {
        if self.first_page_rows == rows {
            return;
        }
        self.first_page_rows = rows;
        self.series_cache.clear();
    }

    /// 全辅音输入里原来打头的纠错候选最多退到的座位:首页最后一个,最小为 1(`lead_literal_abbreviation_phrases`)。
    fn abbreviation_kept_seat(&self) -> usize {
        self.first_page_rows.get().saturating_sub(1).max(1)
    }

    /// Loads the keyboard sentence model from the resource bundle when its switch is on. The desktop model is not the engine's: only the runtime's settled reranker runs it. A change resets the caches (neural-association.patch:2667-2682), as shuangpin does, so a cached online row does not come back after the options are switched away and back.
    pub fn set_sentence_association(&mut self, options: SentenceAssociationOptions) {
        if self.sentence_association == options {
            return;
        }
        // 模型路径为空或不可载时回退 `None`:与 `msime-bigram.bin` 缺失的既有降级一致。
        self.collocation_db = if options.collocation_model.as_os_str().is_empty() {
            None
        } else {
            crate::lattice::octagram::GramDb::shared(&options.collocation_model)
        };
        self.sentence_association = options.clone();
        self.rerankers.clear();
        if options.neural_keyboard {
            if let Some(model) = shared_sentence_model(&self.paths.resource(NEURAL_MODEL_KEYBOARD))
            {
                self.rerankers
                    .push(NeuralReranker::new(CandidateSource::NeuralKeyboard, model));
            }
        }
        self.reset_cache();
    }

    /// Keeps the part of the committed text the rerankers read; while they run it is part of every series slot (`series_slot`).
    pub fn set_rescoring_context(&mut self, context: &str) {
        // The models only ever see the last `CONTEXT_CHARACTERS`, so committed text beyond that window changes nothing.
        let trimmed = last_characters(context, CONTEXT_CHARACTERS);
        if self.rescoring_context != trimmed {
            self.rescoring_context = trimmed.to_string();
        }
    }

    /// The series cache slot of a resolution's key (overlays.md §1.6.2). The association switches decide which sentence rows a list carries, so they are always part of the slot. The reference bypassed the cache while a neural model ran, because its scores came from an asynchronous worker; scoring here is synchronous, so a reranked list is cached under the trimmed context it was scored with and a commit moves the next query to a fresh slot instead of clearing every list.
    fn series_slot(&self, cache_key: &str) -> String {
        series_slot_key(
            cache_key,
            &self.sentence_association,
            !self.rerankers.is_empty(),
            &self.rescoring_context,
        )
    }

    /// Clears everything except the resolution memo (QD:1400-1407).
    pub fn reset_cache(&mut self) {
        self.cache.clear();
        self.series_cache.clear();
        self.personal_scored_keys.clear();
        self.segmentation_cache.clear();
        self.fuzzy_cache.clear();
        self.typo_span_cache.clear();
        self.clear_row_caches();
    }

    fn clear_row_caches(&mut self) {
        // 整句是从这些行解出来的。
        self.sentence_decode_cache.clear();
        self.lattice_span_cache.clear();
        self.segment_row_cache.clear();
        self.longer_row_cache.clear();
        self.fuzzy_row_cache.clear();
    }

    /// 九键一次刷新要连着查几十条切分。批开始时开一个读事务(见 `PinyinDatabase::begin_read`),在事务里对一次版本,之后到返回的 `RowCacheBatch` 被丢弃为止的查询都读同一份快照,行缓存也不再逐条对版本。词典是回滚日志模式,读事务拿着共享锁时别的连接提交不了,只能在 `BUSY_TIMEOUT` 内忙等,所以每段事务最多持续 `ROW_CACHE_BATCH_SPAN`,到时在下一次真正计算查询前提交、重开并重新对版本,别的连接写词典最多多等这一段。批在 `Drop` 里结束,查询中途 panic(宿主在 FFI 边界 `catch_unwind` 后继续运行)也不会把事务和共享锁留在连接上。
    pub fn row_cache_batch(&mut self) -> RowCacheBatch<'_> {
        self.row_cache_batch = None;
        self.database.begin_read();
        self.validate_row_caches();
        self.row_cache_batch = Some(Instant::now());
        RowCacheBatch { dictionary: self }
    }

    /// 只读当前版本号,不改 `database_changed` 记下的那份,所以其他缓存何时重置和以前完全一样。读不到版本(词典没打开或读取失败)时每次都清空,等于不缓存。
    fn validate_row_caches(&mut self) {
        if let Some(started) = self.row_cache_batch {
            if started.elapsed() < ROW_CACHE_BATCH_SPAN {
                return;
            }
            // 这一段读事务够长了,让别的连接有机会提交,再从新快照接着读。
            self.database.end_read();
            self.database.begin_read();
            self.row_cache_batch = Some(Instant::now());
        }
        let version = self.database.data_version();
        if version.is_none() || version != self.row_cache_version {
            self.clear_row_caches();
            self.row_cache_version = version;
        }
    }

    /// QD:320-482.
    fn query_exact(&mut self, raw: &str, segmentation: &str, types: u32) -> Vec<WordItem> {
        self.query_exact_projected(raw, segmentation, types, ColdRows::Transfer, |rows, _| {
            rows.into_owned()
        })
    }

    /// 同步投影只能筛选、复制或移交原行,不改字段和顺序;冷页全保留时可交换副本与原页。
    /// 回调的布尔值只标识核对失效后的系列热命中,借用的冷页仍为 `false`。
    fn query_exact_projected<P: Into<ProjectedRows>>(
        &mut self,
        raw: &str,
        segmentation: &str,
        types: u32,
        cold: ColdRows,
        project: impl FnOnce(Cow<'_, [WordItem]>, bool) -> P,
    ) -> Vec<WordItem> {
        if raw.is_empty() {
            return project(Cow::Owned(Vec::new()), false).into().selected();
        }
        self.current_autocorrect_types = types;
        if types & TYPO_EDGE_TYPES != 0 {
            // Only a stat per query unless the journal changed. Lists built without a mask never depend on the profile.
            self.typo_profile.refresh_if_changed();
            let generation = self.typo_profile.generation();
            if generation != self.typo_generation {
                self.typo_generation = generation;
                self.series_cache.clear();
                // 纠错整句按纠错资料规划。
                self.sentence_decode_cache.clear();
                self.personal_scored_keys.clear();
            }
        }
        let mut segments = None;
        let segmentation_cache = &mut self.segmentation_cache;
        let association = self.sentence_association.clone();
        let has_rerankers = !self.rerankers.is_empty();
        let context = &self.rescoring_context;
        let pinyin_segmentation = &mut self.pinyin_segmentation;
        let (resolution_hash, (resolution, marking_alternatives, slot)) =
            project_cached_resolution_with(
                &mut self.resolution_cache,
                raw,
                segmentation,
                types,
                || {
                    let segments = segments.get_or_insert_with(|| {
                        resolve_cached_segments(segmentation_cache, raw, segmentation)
                    });
                    resolve_series_query(raw, segments, types)
                },
                |resolution| {
                    // 热命中也发布本次标记;高成本读法仍算纠错。
                    pinyin_segmentation.clone_from(&resolution.segmentation);
                    let alternatives = resolution
                        .alternative_corrected_cuts
                        .iter()
                        .chain(&resolution.costlier_corrected_cuts)
                        .map(|cut| join_segments(cut))
                        .collect();
                    let slot = series_slot_key(
                        &resolution.cache_key,
                        &association,
                        has_rerankers,
                        context,
                    );
                    let owned = match resolution {
                        Cow::Borrowed(_) => None,
                        Cow::Owned(resolution) => Some(resolution),
                    };
                    (owned, alternatives, slot)
                },
            );
        self.alternative_segmentations = marking_alternatives;
        self.drop_personal_scored_results();
        if self.series_cache.contains(&slot) {
            if self.database.database_changed() {
                // 清空切分备忘前取得重建需要的音节,保留原先的缓存复用与分配成本。
                segments.get_or_insert_with(|| self.resolve_segments(raw, segmentation));
                self.reset_cache();
            }
            if let Some(cached) = self.series_cache.get_ref(&slot) {
                return project(Cow::Borrowed(cached), true).into().selected();
            }
        }

        let segments = segments.unwrap_or_else(|| self.resolve_segments(raw, segmentation));

        // 候选槽失效不会改解析备忘;复用已核对的索引,避免重复哈希和完整键校验。
        let resolution = resolution.unwrap_or_else(|| {
            self.resolution_cache
                .get_ref(&resolution_hash)
                .expect("候选失效检查必须保留解析备忘")
                .resolution
                .clone()
        });

        // 下面每一次读词典都可能走行缓存,先确认它们还和库里一致。
        self.validate_row_caches();
        let alternatives = alternative_segmentations(raw, &segments, &resolution, types);
        let primary_segmentation = resolution.segmentation.as_str();
        let mut result;
        if resolution.corrected_input {
            // The corrected reading is the primary key, so selection and weight updates land on the right rows; the literal reading stays behind it as a fallback tail.
            result = self.query_series(raw, primary_segmentation, &resolution.corrected);
            let literal = if segmentation.is_empty() {
                let literal_segmentation = join_segments(&segments);
                self.query_series(raw, &literal_segmentation, &segments)
            } else {
                self.query_series(raw, segmentation, &segments)
            };
            append_unique_words(&mut result, literal);
            // Same-cost readings of the typo compete with the primary cut on dictionary frequency, and the best keeps a protected slot near the top.
            if !alternatives.is_empty() {
                result = self.merge_alternatives(
                    raw,
                    primary_segmentation,
                    &resolution.corrected,
                    &alternatives,
                    result,
                );
            }
            // Costlier readings go below the whole primary tier, so a frequent dearer correction (gau -> gai, weight 13) never precedes the cheaper one (gau -> gua, weight 10).
            for costlier in &resolution.costlier_corrected_cuts {
                let rows = self.query_series(raw, &join_segments(costlier), costlier);
                append_unique_words(&mut result, rows);
            }
            // 全辅音输入是简拼:字面简拼读满全部按键的多字词条排到纠错读法前面,打头的纠错候选最多退到首页最后一个座位,首位永远不让给它(#9903)。
            lead_literal_abbreviation_phrases(&mut result, raw, self.abbreviation_kept_seat());
        } else {
            result = self.query_series(raw, primary_segmentation, &segments);
            if !alternatives.is_empty() {
                result = self.merge_alternatives(
                    raw,
                    primary_segmentation,
                    &segments,
                    &alternatives,
                    result,
                );
            }
        }
        let (cached, projected) = match cold {
            ColdRows::Transfer => {
                let cached = result.clone();
                (
                    Some(cached),
                    project(Cow::Owned(result), false).into().selected(),
                )
            }
            ColdRows::Borrow => {
                match project(Cow::Borrowed(&result), false).into() {
                    ProjectedRows::All => (None, result),
                    ProjectedRows::Selected(projected) => {
                        if projected.len() == result.len() {
                            // 既有投影回调已复制的全页仍直接入缓存。
                            (Some(projected), result)
                        } else {
                            // 外层富余容量不随整页转移而长期留在 FIFO 中。
                            result.shrink_to_fit();
                            (Some(result), projected)
                        }
                    }
                }
            }
        };
        // 单音节主路径也可能沿完整备选切分组句,需要跟随个人模型失效。
        let personal_scored = segments.len() >= 2
            || resolution.corrected.len() >= 2
            || alternatives
                .iter()
                .any(|path| path.len() >= 2 && is_literal_segmentation(raw, path));
        let personal_slot = personal_scored.then(|| slot.clone());
        if let Some(cached) = cached {
            self.series_cache.insert(slot, cached);
        } else {
            let (cached, previous) = self.series_cache.insert_with_previous(slot, Vec::new());
            *cached = page_copy::clone_rows_reusing(&projected, previous.unwrap_or_default());
        }
        if let Some(slot) = personal_slot {
            self.remember_personal_scored_key(slot);
        }
        projected
    }

    /// 完整词继续按词频合并,字面备选另外补齐前缀和整句,不能叠加一层纠错。
    fn merge_alternatives(
        &mut self,
        raw: &str,
        primary_segmentation: &str,
        primary_segments: &[String],
        alternatives: &[Vec<String>],
        result: Vec<WordItem>,
    ) -> Vec<WordItem> {
        let mut result = self.merge_alternative_full(
            raw,
            primary_segmentation,
            primary_segments,
            alternatives,
            result,
        );
        let mut supplemental = Vec::new();
        // 相同音节前缀在多条完整路径中会重复出现;它们的行和消费范围相同,只查一次。
        let mut queried_prefixes = HashMap::new();
        if is_literal_segmentation(raw, primary_segments) {
            for count in 1..primary_segments.len() {
                let key = join_segments(&primary_segments[..count]);
                if let Some(rows) = self.cache.get_ref_by(&key) {
                    queried_prefixes.insert(key, rows.len());
                }
            }
        }
        for path in alternatives {
            if is_literal_segmentation(raw, path) {
                let rows = self.query_series_with_typos(
                    raw,
                    &join_segments(path),
                    path,
                    false,
                    Some(&mut queried_prefixes),
                );
                supplemental.extend(rows);
            }
        }
        if !supplemental.is_empty() {
            merge_series_by_coverage(&mut result, supplemental);
        }
        result
    }

    /// `merge_alternative_segmentations` 的完整词查询部分,升位与合并规则在 `series`。
    fn merge_alternative_full(
        &mut self,
        raw: &str,
        primary_segmentation: &str,
        primary_segments: &[String],
        alternatives: &[Vec<String>],
        result: Vec<WordItem>,
    ) -> Vec<WordItem> {
        let rows = self.database.query_exact_segmentations_keyed_flat(
            alternatives,
            ALTERNATIVE_SEGMENTATION_CANDIDATE_LIMIT,
        );
        if rows.is_empty() {
            return result;
        }
        let cache_key = path_cache_key(raw, primary_segmentation);
        enum PrimaryPage<'a> {
            Cached(&'a PathPage),
            Owned(Vec<WordItem>),
        }
        let primary_full = if let Some(primary_full) = self.cache.get_ref_by(cache_key) {
            PrimaryPage::Cached(primary_full)
        } else {
            PrimaryPage::Owned(self.query_single_path_miss(
                cache_key,
                primary_segmentation,
                primary_segments,
            ))
        };
        let alternative_full = rows
            .into_iter()
            .map(|row| {
                WordItem::new(
                    row.key.clone(),
                    row.value,
                    row.weight,
                    CandidateSource::Database,
                    row.key,
                )
            })
            .collect();
        match primary_full {
            PrimaryPage::Cached(page) => {
                merge_alternative_rows(result, page.cloned_rows(cache_key), alternative_full)
            }
            PrimaryPage::Owned(rows) => {
                merge_alternative_segmentations(result, rows, alternative_full)
            }
        }
    }

    fn append_query_rows(result: &mut Vec<WordItem>, rows: Vec<WordItem>) {
        if result.is_empty() && result.capacity() == 0 {
            *result = rows;
            return;
        }
        result.reserve(rows.len());
        result.extend(rows);
    }

    /// 对应 QD:560-730:从长到短查前缀组,再合并词网格、纠错整句与稀疏回退。
    fn query_series(
        &mut self,
        raw: &str,
        segmentation: &str,
        segments: &[String],
    ) -> Vec<WordItem> {
        self.query_series_with_typos(raw, segmentation, segments, true, None)
    }

    fn query_series_with_typos(
        &mut self,
        raw: &str,
        segmentation: &str,
        segments: &[String],
        include_typos: bool,
        mut queried_prefixes: Option<&mut HashMap<String, usize>>,
    ) -> Vec<WordItem> {
        if segments.is_empty() {
            return self.query_single_path(raw, segmentation, segments);
        }
        let mut result = Vec::new();
        let mut skipped_prefix_rows = 0usize;
        let prefix_key = series_prefix_key(segmentation, segments);
        let mut partial_segmentation = prefix_key.as_ref();
        for count in (1..=segments.len()).rev() {
            if count < segments.len() {
                partial_segmentation = partial_segmentation
                    .rsplit_once('\'')
                    .map_or("", |(prefix, _)| prefix);
            }
            let partial = &segments[..count];
            if count < segments.len() {
                if let Some(prefixes) = queried_prefixes.as_deref_mut() {
                    if let Some(&count) = prefixes.get(partial_segmentation) {
                        skipped_prefix_rows = skipped_prefix_rows.saturating_add(count);
                        continue;
                    }
                }
                let before = result.len();
                self.append_single_path(&mut result, raw, partial_segmentation, partial);
                self.append_initial_split_rows(&mut result, raw, partial);
                if let Some(prefixes) = queried_prefixes.as_deref_mut() {
                    prefixes.insert(partial_segmentation.to_owned(), result.len() - before);
                }
                continue;
            }
            let mut rows = self.query_single_path(raw, partial_segmentation, partial);
            if self.append_initial_split_rows(&mut rows, raw, partial) {
                rows.sort_by_key(|item| std::cmp::Reverse(item.weight));
            }
            if count == segments.len() {
                // 同长度表里常常只剩冷僻同音词:ping'guo 只有 苹果 1143881、评过 1180、平果 169、平锅 1,而 苹果电脑 21495、苹果公司 19725 在 tbl_4_p,同长度查询看不到,第二位就落到冷僻同音词上。所以更长的补全词按权重并入这一组,权重出自同一语料,跨表可比。`pinyin` 仍是键入的切分(上屏只消耗已键入的音节),`canonical_pinyin` 保留完整读音供持久化。
                // 但首位只留给读音和键入完全一致的同长度行里最重的那一行:补全词上屏会多出用户没打的字,权重再高也不能抢首选(#7345:dacuozi 的首选曾是 打错字了,doumeiren 的首选曾是 都没人管理)。补全词只和其余同长度行竞争第二位起的位置,苹果电脑 仍排在 平锅 前面。同长度查询没有精确键时会退到前缀区间(zhi'liang'jian 只查到 zhi'liang'jiang 的 质量奖),那一行的读音并不是键入的读音,不钉首位,和补全词一起按权重排;同长度查询一行都没有时同样按权重排,整句块随后由 `whole_sentence_insert_position` 插到首位。
                // 精确同音词自己够填首页时,补全词不再按权重挤进来,改成接在整组末尾:首页的座位仍归同音词(qi'shi 的首屏曾是 其实 骑士 其实是 企事业单位),但往后翻还翻得到它们(zhongguo、shide 的精确表本来就够一页,直接丢掉等于让「中国人民」「是的话」从候选里消失,词库以后给 ping'guo 多加几个冷僻同音词,「苹果电脑」也会整个不见)。整组本身至少就有 `first_page_rows` 行,所以接在末尾的补全词一定落在首页之后。一页几行取宿主的每页候选数(`first_page_rows`);首页上还有整句块、AI 位、云候选位和后插入的用户词,这个门槛看不见它们,但补全词只是被推后,不再消失。
                let exact_rows = rows
                    .iter()
                    .filter(|item| item.canonical_pinyin == partial_segmentation)
                    .count();
                let longer = self.longer_phrase_candidates(partial_segmentation, partial);
                if !longer.is_empty() {
                    if exact_rows < self.first_page_rows.get() {
                        // `query_database` 和 `segment_row_cache` 都保持词典按权重降序的顺序;这里的页只来自这条路径,不能再被其它来源打乱,所以无需为长词合并重新排序。
                        let lead = usize::from(
                            rows.first()
                                .is_some_and(|item| item.canonical_pinyin == partial_segmentation),
                        );
                        rows.extend(longer);
                        rows[lead..].sort_by_key(|item| std::cmp::Reverse(item.weight));
                    } else {
                        rows.extend(longer);
                    }
                }
            }
            Self::append_query_rows(&mut result, rows);
        }

        if segments.len() >= 2 && has_only_complete_pinyin_segments(segments) {
            if include_typos {
                self.merge_sentences(&mut result, raw, segmentation, segments);
            } else {
                self.merge_sentences_with_typos(&mut result, raw, segmentation, segments, false);
            }
        }

        // 跳过的重复行仍计入原路径的稀疏门槛,不能因此额外触发读音回退。
        if result.len().saturating_add(skipped_prefix_rows) < SPARSE_FALLBACK_THRESHOLD {
            for fallback in sparse_pinyin_fallback_segments(segments) {
                if fallback.is_empty() {
                    continue;
                }
                let fallback_segmentation = join_segments(&fallback);
                let rows = self.query_single_path(raw, &fallback_segmentation, &fallback);
                append_unique_words(&mut result, rows);
            }
        }
        result
    }

    /// 自动切分里的双字母声母另试单字母读法;手动边界不改,完整音节不拆。
    fn append_initial_split_rows(
        &mut self,
        result: &mut Vec<WordItem>,
        raw: &str,
        segments: &[String],
    ) -> bool {
        // 单独的声母前缀仍只查单字,避免把错误拼写开头的 sh 扩成「上好」并遮住纠错关闭的边界。
        if segments.len() < 2 || raw.contains('\'') {
            return false;
        }
        let before = result.len();
        for path in split_initial_token_paths(segments, SYLLABLE_GRAPH_PATH_LIMIT) {
            let code = join_segments(&path);
            let rows = self.query_single_path(raw, &code, &path);
            append_unique_words(result, rows);
        }
        result.len() != before
    }

    /// The lattice sentence block and the typo sentence (QD:644-721, overlays.md §1.6.2). Both rank below a dictionary row that already answers the whole key, which is what `whole_sentence_insert_position` computes; inserting at index 0 once turned 百依百顺 into 白一百顺 and 颁布实施 into 版不是是.
    fn merge_sentences(
        &mut self,
        result: &mut Vec<WordItem>,
        raw: &str,
        segmentation: &str,
        segments: &[String],
    ) {
        self.merge_sentences_with_typos(result, raw, segmentation, segments, true);
    }

    fn merge_sentences_with_typos(
        &mut self,
        result: &mut Vec<WordItem>,
        raw: &str,
        segmentation: &str,
        segments: &[String],
        include_typos: bool,
    ) {
        let association = self.sentence_association.clone();
        let has_collocation =
            self.collocation_db.is_some() && association.collocation_weight != 0.0;
        // 〔搭配〕行即解码最优路径:模型在且显示开关开时才出。
        let include_collocation_best = association.collocation_rerank && has_collocation;
        if !association.word_lattice && self.rerankers.is_empty() && !include_collocation_best {
            return;
        }
        // Held for the whole merge: the options borrow the model.
        let personal = Arc::clone(&self.personal);
        let model = personal.model();
        let mut options = self.sentence_tables.options(self.sentence_alternatives);
        if !self.rerankers.is_empty() {
            options.nbest = MAX_RERANK_PATHS;
        }
        options.include_lattice_best = association.word_lattice;
        options.include_collocation_best = include_collocation_best;
        options.show_next_on_duplicate = association.show_next_on_duplicate;
        options.personal = Some(&*model);
        options.bigram_memo = Some(&self.bigram_memo);
        // 搭配项逐边叠加字级搭配分,与二元/三元在同一 Viterbi 里竞争;
        // query() 是自然对数域,乘 LN_TO_LOG10 换到主分的域。
        let scorer;
        if has_collocation {
            let db = self.collocation_db.as_ref().expect("collocation db loaded");
            let config = association.collocation_grammar;
            scorer = move |tail: &str, word: &str, is_rear: bool| {
                db.query(tail, word, is_rear, &config) * std::f64::consts::LOG10_E
            };
            options.collocation_scorer = Some(&scorer);
            options.collocation_weight = association.collocation_weight;
        }

        let typed = if segmentation.is_empty() {
            raw
        } else {
            segmentation
        };
        let types = self.current_autocorrect_types;
        let typo_eligible = include_typos && types & TYPO_EDGE_TYPES != 0 && !raw.contains('\'');
        let span_limit = options.span_limit;
        let database = &self.database;
        let span_cache = &mut self.typo_span_cache;
        let profile = &*self.typo_profile;
        let mut typo_edges = |literal_best: &SentencePath| {
            collect_typo_edges(database, span_cache, profile, segments, literal_best, types)
        };
        let typo_source: Option<&mut TypoEdgeSource<'_>> = if typo_eligible {
            Some(&mut typo_edges)
        } else {
            None
        };
        // 模型已按需重载,此刻的版本就是这次解码用的版本。
        let personal_version = personal.version();
        let decode_key = sentence_decode_key(segments, types, typo_eligible);
        let cached = self
            .sentence_decode_cache
            .get_ref(&decode_key)
            .filter(|cached| cached.personal_version == personal_version);
        let typo = if let Some(cached) = cached {
            merge_decoded_borrowed(
                result,
                segments,
                typed,
                &options,
                &mut self.rerankers,
                &self.rescoring_context,
                &cached.decoded,
            )
        } else {
            let span_count = lattice_span_count(segments.len(), options.max_phrase_syllables);
            let decoded = if span_count <= LATTICE_SPAN_CACHE_CAPACITY {
                warm_lattice_span_cache(
                    database,
                    &mut self.lattice_span_cache,
                    segments,
                    span_limit,
                    options.max_phrase_syllables,
                );
                let lattice_spans = &self.lattice_span_cache;
                let mut lookup = |span: &[String]| {
                    cached_lattice_span_ref(lattice_spans, span, span_limit)
                        .expect("warmed lattice span cache entry")
                };
                decode_lattice_borrowed(segments, &mut lookup, &options, typo_source)
            } else {
                let lattice_spans = &mut self.lattice_span_cache;
                let mut lookup = |span: &[String]| {
                    cached_lattice_span(database, lattice_spans, span, span_limit)
                };
                decode_lattice(segments, &mut lookup, &options, typo_source)
            };
            if let Some(decoded) = &decoded {
                self.sentence_decode_cache.insert(
                    decode_key,
                    CachedDecode {
                        personal_version,
                        decoded: decoded.clone(),
                    },
                );
            }
            decoded.and_then(|decoded| {
                merge_decoded(
                    result,
                    segments,
                    typed,
                    &options,
                    &mut self.rerankers,
                    &self.rescoring_context,
                    decoded,
                )
            })
        };

        // A sentence that reads one or more syllables as a typo of another. It leads only when it beats the literal sentence by the lead margin per typo; otherwise it follows the whole-sentence rows, so a correction is offered without displacing what was typed.
        let Some(typo) = typo else {
            return;
        };
        if result.iter().any(|item| item.word == typo.sentence) {
            return;
        }
        let mut at = whole_sentence_insert_position(result, segments);
        if !typo.leads(options.typo_lead_margin) {
            while at < result.len() && result[at].sentence_association {
                at += 1;
            }
        }
        let mut sentence = WordItem::new(
            typed,
            typo.sentence,
            (typo.score * 1000.0) as i64,
            CandidateSource::Generated,
            typo.key,
        );
        sentence.sentence_association = true;
        sentence.sentence_words = typo.words;
        sentence.corrected_from = fold_reading(raw).into();
        result.insert(at, sentence);
    }

    /// 完整音节的长词续接(QD:732-749):输入切分作为 `pinyin`,完整长键作为规范键。
    fn longer_phrase_candidates(
        &mut self,
        segmentation: &str,
        segments: &[String],
    ) -> Vec<WordItem> {
        if let Some(rows) = self.longer_row_cache.get_ref_by(segmentation) {
            return matched_items(rows.iter().cloned(), segmentation);
        }
        let rows = self.database.query_longer_phrases(
            segments,
            LONGER_PHRASE_EXTRA_SYLLABLES,
            LONGER_PHRASE_LIMIT,
        );
        self.longer_row_cache
            .insert(segmentation.to_owned(), rows.clone());
        matched_items(rows, segmentation)
    }

    /// 单条切分的词典页按原键缓存(QD:751-765),空查询仍为空。
    fn query_single_path(
        &mut self,
        raw: &str,
        segmentation: &str,
        segments: &[String],
    ) -> Vec<WordItem> {
        let cache_key = path_cache_key(raw, segmentation);
        if let Some(cached) = self.cache.get_ref_by(cache_key) {
            return cached.copy_rows(cache_key);
        }
        self.query_single_path_miss(cache_key, segmentation, segments)
    }

    /// 较短热前缀直接克隆到最终结果,避免建立随后立即搬走的中间候选页。
    fn append_single_path(
        &mut self,
        result: &mut Vec<WordItem>,
        raw: &str,
        segmentation: &str,
        segments: &[String],
    ) {
        if result.is_empty() && result.capacity() == 0 {
            *result = self.query_single_path(raw, segmentation, segments);
            return;
        }
        let cache_key = path_cache_key(raw, segmentation);
        if let Some(rows) = self.cache.get_ref_by(cache_key) {
            rows.append_to(cache_key, result);
            return;
        }
        let rows = self.query_single_path_miss(cache_key, segmentation, segments);
        Self::append_query_rows(result, rows);
    }

    /// 冷页仍保存紧凑副本并交出原查询结果;两个入口共用同一次未命中处理。
    fn query_single_path_miss(
        &mut self,
        cache_key: &str,
        segmentation: &str,
        segments: &[String],
    ) -> Vec<WordItem> {
        let result = self.query_database(segments, segmentation);
        self.cache.insert(
            cache_key.to_owned(),
            PathPage::from_dictionary(cache_key, &result),
        );
        result
    }

    /// QD:830-869:单字母查首字母表,最多 24 行;其它输入走原级联查询。
    fn query_database(&mut self, segments: &[String], segmentation: &str) -> Vec<WordItem> {
        if segments.len() == 1 && segments[0].len() == 1 {
            let matched_code = if segmentation.is_empty() {
                segments[0].as_str()
            } else {
                segmentation
            };
            // 单字节 UTF-8 音节必为 ASCII;局部键只供查找,前缀与普通切分键分开。
            let key_bytes = [1, segments[0].as_bytes()[0]];
            let key = std::str::from_utf8(&key_bytes).expect("单字节音节与缓存前缀必须是 ASCII");
            if let Some(rows) = self.segment_row_cache.get_ref_by(key) {
                return matched_items(rows.iter().cloned(), matched_code);
            }
            let rows = self
                .database
                .query_initial(&segments[0], INITIAL_CANDIDATE_LIMIT);
            self.segment_row_cache
                .insert(format!("\u{1}{}", segments[0]), rows.clone());
            return matched_items(rows, matched_code);
        }
        // 音节已在 `resolve_segments` 规范化一次,直接使用标准键查找。
        let code = segmentation.is_empty().then(|| join_segments(segments));
        let key = code.as_deref().unwrap_or(segmentation);
        if let Some(rows) = self.segment_row_cache.get_ref_by(key) {
            return matched_items(rows.iter().cloned(), key);
        }
        let rows =
            self.database
                .query_segments_keyed_flat(segments, UNLIMITED_ROWS, QuerySource::Quanpin);
        self.segment_row_cache.insert(key.to_owned(), rows.clone());
        matched_items(rows, key)
    }

    /// 显式和自动切分共用一次别名规范化,进入查询管线的音节保持标准拼写(QD:767-775)。
    fn resolve_segments(&mut self, raw: &str, segmentation: &str) -> Vec<String> {
        resolve_cached_segments(&mut self.segmentation_cache, raw, segmentation)
    }

    /// 拥有型解析入口只保留作旧查询的测试对照;产品按已核对的索引延迟取得副本。
    #[cfg(test)]
    fn resolution(
        &mut self,
        raw: &str,
        segmentation: &str,
        segments: &[String],
        types: u32,
    ) -> SeriesResolution {
        project_cached_resolution(
            &mut self.resolution_cache,
            raw,
            segmentation,
            segments,
            types,
            |resolution| resolution.into_owned(),
        )
        .1
    }

    /// 热投影不构造音节;冷未命中保留原音节备忘和解析副本,并交出计算结果。
    fn project_resolution<T>(
        &mut self,
        raw: &str,
        segmentation: &str,
        types: u32,
        project: impl FnOnce(Cow<'_, SeriesResolution>) -> T,
    ) -> T {
        let segmentation_cache = &mut self.segmentation_cache;
        project_cached_resolution_with(
            &mut self.resolution_cache,
            raw,
            segmentation,
            types,
            || {
                let segments = resolve_cached_segments(segmentation_cache, raw, segmentation);
                resolve_series_query(raw, &segments, types)
            },
            project,
        )
        .1
    }

    /// Insert a row that is not there yet and journal it as the user's own (QD:1200-1212, QD:1234-1266). A row already present is success without a journal write.
    fn insert_new_word(&mut self, key: &str, word: &str) -> Result<()> {
        if !self.database.is_open() {
            return Err(EngineError::failed(DICTIONARY_UNAVAILABLE));
        }
        if self.database.find_weight(key, word).is_some() {
            return Ok(());
        }
        self.database.insert_word(key, word)?;
        // The reference ignored a failed journal write. The row is in the working dictionary either way, so the caches are reset first; the error still reaches the caller, because a row missing from the journal is lost at the next generation.
        let journaled = record_user_insert(
            &self.paths.user(USER_JOURNAL),
            PersonalDictionaryKind::Pinyin,
            key,
            word,
            INSERTED_WEIGHT,
            "",
        );
        self.reset_cache();
        journaled
    }

    /// QD:1409-1432.
    fn reset_cache_if_database_changed(&mut self) {
        if self.database.database_changed() {
            self.reset_cache();
        }
    }

    /// QD:283-293.
    fn drop_personal_scored_results(&mut self) {
        // Reading the model reloads it when another store or process changed the tables.
        drop(self.personal.model());
        let version = self.personal.version();
        if version == self.personal_version {
            return;
        }
        self.personal_version = version;
        for key in self.personal_scored_keys.drain() {
            self.series_cache.remove(&key);
        }
    }

    /// QD:271-281.
    fn remember_personal_scored_key(&mut self, key: String) {
        self.personal_scored_keys.insert(key);
        // The series cache evicts on its own, so keys it no longer holds are dropped here; otherwise the set grows for as long as the model's version stays put.
        if self.personal_scored_keys.len() > PERSONAL_SCORED_KEY_LIMIT {
            let series_cache = &self.series_cache;
            self.personal_scored_keys
                .retain(|key| series_cache.contains(key));
        }
    }
}

/// 按缓存字段拆分借用,使调用方能同时读取系列槽配置;完整键核对和冷副本规则集中保留。
#[cfg(test)]
fn project_cached_resolution<T>(
    cache: &mut FifoCache<u64, CachedResolution>,
    raw: &str,
    segmentation: &str,
    segments: &[String],
    types: u32,
    project: impl FnOnce(Cow<'_, SeriesResolution>) -> T,
) -> (u64, T) {
    project_cached_resolution_with(
        cache,
        raw,
        segmentation,
        types,
        || resolve_series_query(raw, segments, types),
        project,
    )
}

/// 解析热命中不调用构造闭包;常规查询因此可把音节拥有值延迟到真正未命中时。
fn project_cached_resolution_with<T>(
    cache: &mut FifoCache<u64, CachedResolution>,
    raw: &str,
    segmentation: &str,
    types: u32,
    resolve: impl FnOnce() -> SeriesResolution,
    project: impl FnOnce(Cow<'_, SeriesResolution>) -> T,
) -> (u64, T) {
    let hash = resolution_cache_hash(types, raw, segmentation);
    if let Some(cached) = cache.get_ref(&hash) {
        if resolution_cache_key_matches(&cached.key, types, raw, segmentation) {
            return (hash, project(Cow::Borrowed(&cached.resolution)));
        }
    }
    let resolution = resolve();
    let key = format!("{types}\u{1f}{raw}\u{1f}{segmentation}");
    cache.insert(
        hash,
        CachedResolution {
            key,
            resolution: resolution.clone(),
        },
    );
    (hash, project(Cow::Owned(resolution)))
}

/// 直接构造拥有型候选;缓存行可用惰性克隆迭代,避免完整的中间行页。
fn matched_items(rows: impl IntoIterator<Item = DictRow>, matched_code: &str) -> Vec<WordItem> {
    rows.into_iter()
        .map(|row| {
            WordItem::new(
                matched_code,
                row.value,
                row.weight,
                CandidateSource::Database,
                row.key,
            )
        })
        .collect()
}

/// `tbl_1_<c>` 的行转成 `WordItem(key, value, weight, Database, key)`。
fn initial_items(rows: Vec<DictRow>) -> Vec<WordItem> {
    rows.into_iter()
        .map(|row| {
            WordItem::new(
                row.key.clone(),
                row.value,
                row.weight,
                CandidateSource::Database,
                row.key,
            )
        })
        .collect()
}

/// `query_lattice_span` 经过跨度缓存;哈希碰撞时重新查库,不混用不同跨度或上限的结果。
/// 跨度来自已切分的音节,每项不含撇号,因此分隔符编码能核对原样音节边界。
fn cached_lattice_span(
    database: &PinyinDatabase,
    cache: &mut FifoCache<u64, CachedLatticeSpan>,
    span: &[String],
    span_limit: usize,
) -> Vec<DictRow> {
    let hash = lattice_span_cache_hash(span_limit, span);
    if let Some(cached) = cache.get_ref(&hash) {
        if lattice_span_cache_key_matches(cached, span_limit, span) {
            return cached.rows.clone();
        }
    }
    let rows = database.query_lattice_span(span, span_limit);
    let mut key = String::with_capacity(span.iter().map(|syllable| syllable.len() + 1).sum());
    for syllable in span {
        key.push('\'');
        key.push_str(syllable);
    }
    cache.insert(
        hash,
        CachedLatticeSpan {
            span_limit,
            span: key,
            rows: rows.clone(),
        },
    );
    rows
}

fn ensure_cached_lattice_span(
    database: &PinyinDatabase,
    cache: &mut FifoCache<u64, CachedLatticeSpan>,
    span: &[String],
    span_limit: usize,
) {
    let hash = lattice_span_cache_hash(span_limit, span);
    if let Some(cached) = cache.get_ref(&hash) {
        if lattice_span_cache_key_matches(cached, span_limit, span) {
            // 预热批次可能命中上一轮缓存;把命中项移到队尾,避免后续冷跨度插入时把本轮早先命中的项驱逐,导致借用查询找不到刚预热的行。
            let cached = cache
                .remove(&hash)
                .expect("lattice span cache hit disappeared");
            cache.insert(hash, cached);
            return;
        }
    }
    let rows = database.query_lattice_span(span, span_limit);
    let mut key = String::with_capacity(span.iter().map(|syllable| syllable.len() + 1).sum());
    for syllable in span {
        key.push('\'');
        key.push_str(syllable);
    }
    cache.insert(
        hash,
        CachedLatticeSpan {
            span_limit,
            span: key,
            rows,
        },
    );
}

fn cached_lattice_span_ref<'a>(
    cache: &'a FifoCache<u64, CachedLatticeSpan>,
    span: &[String],
    span_limit: usize,
) -> Option<&'a [DictRow]> {
    let hash = lattice_span_cache_hash(span_limit, span);
    let cached = cache.get_ref(&hash)?;
    lattice_span_cache_key_matches(cached, span_limit, span).then_some(cached.rows.as_slice())
}

fn warm_lattice_span_cache(
    database: &PinyinDatabase,
    cache: &mut FifoCache<u64, CachedLatticeSpan>,
    syllables: &[String],
    span_limit: usize,
    max_phrase_syllables: usize,
) {
    let max_len = max_phrase_syllables.max(1);
    for start in 0..syllables.len() {
        for end in start + 1..=syllables.len().min(start + max_len) {
            ensure_cached_lattice_span(database, cache, &syllables[start..end], span_limit);
        }
    }
}

/// `sentence_decode_cache` 的键:音节串、这次查询的纠错类型和是否规划纠错整句。纠错整句只在后两者允许时才解,同一串音节在两种情况下的结果不同。
fn sentence_decode_key(segments: &[String], types: u32, typo_eligible: bool) -> String {
    let mut key = String::with_capacity(
        segments
            .iter()
            .map(|segment| segment.len() + 1)
            .sum::<usize>()
            + 12,
    );
    for (index, segment) in segments.iter().enumerate() {
        if index > 0 {
            key.push('\'');
        }
        key.push_str(segment);
    }
    key.push('\u{1f}');
    key.push_str(&types.to_string());
    key.push(if typo_eligible { 't' } else { '-' });
    key
}

fn lattice_span_count(syllable_count: usize, max_phrase_syllables: usize) -> usize {
    let max_len = max_phrase_syllables.max(1);
    (0..syllable_count)
        .map(|start| syllable_count.min(start.saturating_add(max_len)) - start)
        .sum()
}

fn lattice_span_cache_hash(span_limit: usize, span: &[String]) -> u64 {
    let mut hasher = DefaultHasher::new();
    span_limit.hash(&mut hasher);
    span.hash(&mut hasher);
    hasher.finish()
}

fn lattice_span_cache_key_matches(
    cached: &CachedLatticeSpan,
    span_limit: usize,
    span: &[String],
) -> bool {
    if cached.span_limit != span_limit {
        return false;
    }
    let mut remaining = cached.span.as_str();
    for syllable in span {
        let Some(rest) = remaining.strip_prefix('\'') else {
            return false;
        };
        let Some(rest) = rest.strip_prefix(syllable) else {
            return false;
        };
        remaining = rest;
    }
    remaining.is_empty()
}

// 只有参数字节恰好等于音节拼接结果才借用,保留空段、别名及嵌入撇号的旧键。
fn series_prefix_key<'a>(segmentation: &'a str, segments: &[String]) -> Cow<'a, str> {
    let mut remaining = segmentation;
    for (index, segment) in segments.iter().enumerate() {
        if index != 0 {
            let Some(rest) = remaining.strip_prefix('\'') else {
                return Cow::Owned(join_segments(segments));
            };
            remaining = rest;
        }
        let Some(rest) = remaining.strip_prefix(segment.as_str()) else {
            return Cow::Owned(join_segments(segments));
        };
        remaining = rest;
    }
    if remaining.is_empty() {
        Cow::Borrowed(segmentation)
    } else {
        Cow::Owned(join_segments(segments))
    }
}

#[cfg(test)]
fn truncate_last_segment(segmentation: &mut String) {
    if let Some(boundary) = segmentation.rfind('\'') {
        segmentation.truncate(boundary);
    } else {
        segmentation.clear();
    }
}

fn series_slot_key(
    cache_key: &str,
    options: &SentenceAssociationOptions,
    has_rerankers: bool,
    rescoring_context: &str,
) -> String {
    let context_suffix = if has_rerankers {
        rescoring_context.len() + 1
    } else {
        0
    };
    let mut slot = String::with_capacity(cache_key.len() + 5 + context_suffix);
    slot.push_str(cache_key);
    slot.push('\u{1f}');
    slot.push('S');
    slot.push(if options.word_lattice { '1' } else { '0' });
    slot.push(if options.neural_keyboard { '1' } else { '0' });
    slot.push(if options.show_next_on_duplicate {
        '1'
    } else {
        '0'
    });
    if has_rerankers {
        slot.push('\u{1f}');
        slot.push_str(rescoring_context);
    }
    slot
}

fn fuzzy_segmentation<'a>(segmentation: &'a str, normalized_segments: &[String]) -> Cow<'a, str> {
    if segmentation.is_empty() {
        Cow::Owned(join_segments(normalized_segments))
    } else {
        Cow::Borrowed(segmentation)
    }
}

fn fuzzy_cache_hash(rules: u32, segmentation: &str) -> u64 {
    let mut hasher = DefaultHasher::new();
    rules.hash(&mut hasher);
    segmentation.hash(&mut hasher);
    hasher.finish()
}

fn fuzzy_cache_key_matches(cached: &CachedFuzzyCandidates, rules: u32, segmentation: &str) -> bool {
    cached.rules == rules && cached.segmentation == segmentation
}

/// 热页直接投影,冷页保留原缓存副本与 SQLite 页容量策略。
fn append_cached_fuzzy_rows<'a>(
    database: &PinyinDatabase,
    cache: &mut FifoCache<u64, CachedFuzzyRows>,
    paths: &[Vec<String>],
    rules: u32,
    limit: usize,
    typed: impl Into<Cow<'a, str>>,
    result: &mut Vec<WordItem>,
) {
    let typed = typed.into();
    let hash = fuzzy_row_cache_hash(rules, limit, &typed);
    if let Some(cached) = cache.get_ref(&hash) {
        if fuzzy_row_cache_key_matches(cached, rules, limit, &typed) {
            append_fuzzy_items(cached.rows.iter().cloned(), typed, result);
            return;
        }
    }
    let rows = database.query_exact_segmentations_keyed_flat(paths, FUZZY_ROW_LIMIT);
    cache.insert(
        hash,
        CachedFuzzyRows {
            rules,
            limit,
            typed: typed.as_ref().to_owned(),
            rows: rows.clone(),
        },
    );
    append_fuzzy_items(rows.into_iter(), typed, result);
}

/// 取得首行才拥有匹配码,已有拥有值直接接管;其余行复制同一读法。
fn append_fuzzy_items(
    mut rows: impl ExactSizeIterator<Item = DictRow>,
    typed: Cow<'_, str>,
    result: &mut Vec<WordItem>,
) {
    result.reserve(rows.len());
    if let Some(row) = rows.next() {
        let mut item = WordItem::new(
            typed.into_owned(),
            row.value,
            row.weight,
            CandidateSource::Database,
            row.key,
        );
        item.fuzzy = true;
        result.push(item);
    }
    for row in rows {
        let mut item = WordItem::new(
            result.last().expect("首个模糊候选已插入").pinyin.clone(),
            row.value,
            row.weight,
            CandidateSource::Database,
            row.key,
        );
        item.fuzzy = true;
        result.push(item);
    }
}

fn fuzzy_row_cache_hash(rules: u32, limit: usize, typed: &str) -> u64 {
    let mut hasher = DefaultHasher::new();
    rules.hash(&mut hasher);
    limit.hash(&mut hasher);
    typed.hash(&mut hasher);
    hasher.finish()
}

fn fuzzy_row_cache_key_matches(
    cached: &CachedFuzzyRows,
    rules: u32,
    limit: usize,
    typed: &str,
) -> bool {
    cached.rules == rules && cached.limit == limit && cached.typed == typed
}

fn path_cache_key<'a>(raw: &'a str, segmentation: &'a str) -> &'a str {
    if segmentation.is_empty() {
        raw
    } else {
        segmentation
    }
}

/// 自动切分的冷副本先写入备忘,别名规范化只修改返回值,保留原有缓存容量与内容。
fn resolve_cached_segments(
    cache: &mut FifoCache<String, Vec<String>>,
    raw: &str,
    segmentation: &str,
) -> Vec<String> {
    let mut segments = if segmentation.is_empty() {
        if let Some(cached) = lookup_cached_segments(cache, raw) {
            cached
        } else {
            let segments = first_correction_cut(raw).unwrap_or_default();
            cache.insert(raw.to_owned(), segments.clone());
            segments
        }
    } else {
        split_segments(segmentation)
    };
    normalize_umlaut_aliases(&mut segments);
    segments
}

fn lookup_cached_segments(
    cache: &FifoCache<String, Vec<String>>,
    key: &str,
) -> Option<Vec<String>> {
    cache.get_ref_by(key).cloned()
}

fn resolution_cache_hash(types: u32, raw: &str, segmentation: &str) -> u64 {
    let mut hasher = DefaultHasher::new();
    types.hash(&mut hasher);
    raw.hash(&mut hasher);
    segmentation.hash(&mut hasher);
    hasher.finish()
}

fn resolution_cache_key_matches(key: &str, types: u32, raw: &str, segmentation: &str) -> bool {
    let mut parts = key.split('\u{1f}');
    parts.next().and_then(|part| part.parse().ok()) == Some(types)
        && parts.next() == Some(raw)
        && parts.next() == Some(segmentation)
        && parts.next().is_none()
}

/// The readings that compete with the primary one (QD:394-443): with a mask, the same-cost corrections and then every correction-mode cut; for a short all-complete input, every complete segmentation of the letters. Deduplicated against the primary and the costlier cuts, at most 32.
fn alternative_segmentations(
    raw: &str,
    segments: &[String],
    resolution: &SeriesResolution,
    types: u32,
) -> Vec<Vec<String>> {
    let mut alternatives = Vec::new();
    // Seeding the costlier cuts keeps them out of the frequency-competing tier; they are appended after it.
    let mut seen: [Option<String>; ALTERNATIVE_SEGMENTATION_SEEN_CAPACITY] =
        std::array::from_fn(|_| None);
    let mut seen_length = 0;
    let primary_segmentation = resolution.segmentation.as_str();
    for cut in &resolution.costlier_corrected_cuts {
        remember_segmentation_key(
            &mut seen,
            &mut seen_length,
            primary_segmentation,
            join_segments(cut),
        );
    }
    let mut append = |candidate: Vec<String>| {
        let key = join_segments(&candidate);
        if !key.is_empty()
            && alternatives.len() < SYLLABLE_GRAPH_PATH_LIMIT
            && remember_segmentation_key(&mut seen, &mut seen_length, primary_segmentation, key)
        {
            if alternatives.is_empty() {
                alternatives.reserve(SYLLABLE_GRAPH_PATH_LIMIT);
            }
            alternatives.push(candidate);
        }
    };
    // Correction-mode alternatives follow the autocorrect switch; offering them unconditionally made a misspelled "sahng" produce the corrected word with autocorrection turned off. Correction alternatives go first: they explain the letters actually typed, which the alias readings after them do not.
    if types != 0 {
        for cut in &resolution.alternative_corrected_cuts {
            append(cut.to_vec());
        }
        for cut in cut_pinyin_by_mode(raw, CutMode::Correction) {
            append(cut);
        }
    }
    if !raw.contains('\'')
        && segments.len() <= MAX_SYLLABLES_FOR_MULTIPLE_SEGMENTATIONS
        && has_only_complete_pinyin_segments(segments)
    {
        let graph = build_syllable_graph(raw);
        for path in enumerate_complete_segmentations(&graph, SYLLABLE_GRAPH_PATH_LIMIT) {
            append(path);
        }
    }
    alternatives
}

fn remember_segmentation_key(
    seen: &mut [Option<String>; ALTERNATIVE_SEGMENTATION_SEEN_CAPACITY],
    seen_length: &mut usize,
    primary: &str,
    key: String,
) -> bool {
    if key == primary
        || *seen_length == ALTERNATIVE_SEGMENTATION_SEEN_CAPACITY
        || seen[..*seen_length]
            .iter()
            .flatten()
            .any(|existing| existing == &key)
    {
        return false;
    }
    seen[*seen_length] = Some(key);
    *seen_length += 1;
    true
}

/// 仅接受没有手动边界、没有替换字母的完整切分。
fn is_literal_segmentation(raw: &str, segments: &[String]) -> bool {
    !raw.contains('\'')
        && has_only_complete_pinyin_segments(segments)
        && segments
            .iter()
            .flat_map(|segment| segment.bytes())
            .eq(raw.bytes())
}

/// Letters of a row's matched code, what the fuzzy merge sorts by.
fn matched_letters(pinyin: &str) -> usize {
    pinyin.bytes().filter(|&byte| byte != b'\'').count()
}

/// 整句只按原拼音解码,`whole_sentence_insert_position` 把它放在原拼音覆盖整个输入的词库行之后;它排到首位,通常是因为原拼音没有这样的词,这时它只是替补。开了模糊音,按模糊读法覆盖整个输入的词库行就是这个输入的整词,替补要让给它:`zongguo` 开 z=zh 时「中国」排到整句「总国」前面。同一档里只要还有原拼音覆盖整个输入的精确词库行(整词、另一种切分的整词,哪怕排在更长的补全词后面),就一行也不动,精确仍先于模糊;补全出的更长词条不算覆盖。挪动只在覆盖整个输入的那一档里做,模糊行之间、其余行之间的先后都不变。不开模糊音时不经过这里。
fn yield_stand_in_sentence_to_fuzzy_rows(result: &mut [WordItem]) {
    let Some(first) = result.first() else {
        return;
    };
    if !first.sentence_association {
        return;
    }
    let letters = matched_letters(&first.pinyin);
    let bucket = result
        .iter()
        .take_while(|item| matched_letters(&item.pinyin) == letters)
        .count();
    let typed_reading_answers = result[..bucket].iter().any(|item| {
        !item.fuzzy
            && item.source.is_dictionary()
            && matched_letters(&item.canonical_pinyin) == letters
    });
    if typed_reading_answers {
        return;
    }
    // 稳定排序:模糊行整体挪到前面,各自内部的先后不变。
    result[..bucket].sort_by_key(|item| !item.fuzzy);
}

fn first_correction_cut(pinyin: &str) -> Option<Vec<String>> {
    cut_pinyin_by_mode(pinyin, CutMode::Correction)
        .into_iter()
        .next()
}

#[cfg(test)]
mod tests;

#[cfg(test)]
#[path = "dictionary/alternative_series_tests.rs"]
mod alternative_series_tests;

#[cfg(test)]
#[path = "dictionary/lattice_span_cache_tests.rs"]
mod lattice_span_cache_tests;

#[cfg(test)]
#[path = "dictionary/sentence_decode_cache_tests.rs"]
mod sentence_decode_cache_tests;

#[cfg(test)]
#[path = "dictionary/row_projection_tests.rs"]
mod row_projection_tests;

#[cfg(test)]
#[path = "dictionary/initial_expansion_projection_tests.rs"]
mod initial_expansion_projection_tests;

#[cfg(test)]
#[path = "dictionary/fuzzy_row_projection_tests.rs"]
mod fuzzy_row_projection_tests;

#[cfg(test)]
#[path = "dictionary/resolution_projection_tests.rs"]
mod resolution_projection_tests;

#[cfg(test)]
#[path = "dictionary/primary_segmentation_tests.rs"]
mod primary_segmentation_tests;

#[cfg(test)]
#[path = "dictionary/series_hit_resolution_tests.rs"]
mod series_hit_resolution_tests;

#[cfg(test)]
#[path = "dictionary/lazy_segments_tests.rs"]
mod lazy_segments_tests;

#[cfg(test)]
#[path = "dictionary/slot_key_ownership_tests.rs"]
mod slot_key_ownership_tests;

#[cfg(test)]
#[path = "dictionary/series_prefix_borrow_tests.rs"]
mod series_prefix_borrow_tests;

#[cfg(test)]
#[path = "dictionary/initial_row_key_tests.rs"]
mod initial_row_key_tests;

#[cfg(test)]
#[path = "dictionary/fuzzy_typed_borrow_tests.rs"]
mod fuzzy_typed_borrow_tests;

#[cfg(test)]
#[path = "dictionary/fuzzy_input_borrow_tests.rs"]
mod fuzzy_input_borrow_tests;

#[cfg(test)]
#[path = "dictionary/prefix_row_stream_tests.rs"]
mod prefix_row_stream_tests;

#[cfg(test)]
mod alternative_row_stream_tests;

#[cfg(test)]
mod filtered_query_tests;

#[cfg(test)]
mod empty_projection_tests;

#[cfg(test)]
mod evicted_page_tests;

#[cfg(test)]
mod online_cache_update_tests;