diff --git a/src/ImeWlConverter.Formats/GboardBinary/GboardBinaryImporter.cs b/src/ImeWlConverter.Formats/GboardBinary/GboardBinaryImporter.cs index 1a118df9..1456a57e 100644 --- a/src/ImeWlConverter.Formats/GboardBinary/GboardBinaryImporter.cs +++ b/src/ImeWlConverter.Formats/GboardBinary/GboardBinaryImporter.cs @@ -44,11 +44,13 @@ public Task ImportAsync( entries.Add(new WordEntry { Word = w.Word, - // FPT2 的 F1 字段即词频(Gboard 用它给候选排序, 越大越靠前)。 - // 这里 +1: 上游的 DefaultWordRankGenerator 会把 Rank==0 的条目 - // 覆盖成 1, 而 F1 从 0 开始, 直接映射会让 0 和 1 两个频次撞在一起, - // 同音词的先后顺序就丢了。+1 后既保持相对顺序又不会被覆盖。 - Rank = w.Rank + 1, + // FPT2 的 F1 字段即词频(用户选中次数,官方量级 1~140)。 + // 原样映射:F1=0(从未选过)由构建器或上游的 DefaultWordRankGenerator + // 统一归为 1。 + // 早期这里写 +1(为躲开上游把 Rank==0 覆盖成 1),但那样每轮 + // 「导入→导出」都会把所有词频抬 1,反复往返后全部撞到上限、 + // 同音词的相对顺序丢失。现在构建器自己会把 0 归为 1,无需偏移。 + Rank = w.Rank, CodeType = CodeType.Pinyin, Code = code, }); diff --git a/src/ImeWlConverter.Formats/GboardBinary/GboardDictionaryBuilder.cs b/src/ImeWlConverter.Formats/GboardBinary/GboardDictionaryBuilder.cs index d08df17c..05062745 100644 --- a/src/ImeWlConverter.Formats/GboardBinary/GboardDictionaryBuilder.cs +++ b/src/ImeWlConverter.Formats/GboardBinary/GboardDictionaryBuilder.cs @@ -51,7 +51,7 @@ internal sealed class GboardWord /// 槽号 slot = (val>>1) | (slot_hi<<15)(23 位) /// DA-trie 终端节点 base = 6 × F1 行号 /// 词表按 KEY 路径字节序排序 -/// KEY 的 F1 值全局唯一(同音组平局会导致点选候选时死循环) +/// KEY 的 F1 = 词频(用户选中次数, 官方量级 1~140); 平局正常 /// /// internal static class GboardDictionaryBuilder @@ -336,16 +336,24 @@ private static byte[] Serialize(List items, ICollection? warnings) { var n = items.Count; - // ---- KEY 的 F1: 按 Rank 降序(稳定)分配全局唯一递降值 ---- - // 同音组内若 F1 平局, Gboard 调整候选顺序时会死循环 - var rankOrder = Enumerable.Range(0, n).ToArray(); - Array.Sort(rankOrder, (x, y) => - { - var c = items[y].Rank.CompareTo(items[x].Rank); - return c != 0 ? c : x.CompareTo(y); - }); - for (var pos = 0; pos < n; pos++) - items[rankOrder[pos]].F1 = n - pos; + // ---- KEY 的 F1 = 词频 ---- + // F1 就是 Gboard 记的「用户选中次数」:官方词典里绝大多数词是 1、最高约 140, + // 用户每选一次 +1。所以导入时必须把词频保持在**这个量级**。 + // 早期版本按名次摊成 1..n 的大跨度,实测把 亟需 写成 F1=7775, + // 而用户反复选中的 继续 才涨到 33 —— 候选顺序被永久冻死,再也纠不回来。 + // 平局是正常的:官方词典里 8619 个词同为 F1=1。 + var maxRank = 0; + foreach (var it in items) + if (it.Rank > maxRank) maxRank = it.Rank; + foreach (var it in items) + { + if (it.Rank <= 0) + it.F1 = 1; + else if (maxRank <= 255) + it.F1 = it.Rank; + else + it.F1 = Math.Max(1, (int)Math.Round(255.0 * it.Rank / maxRank)); + } // ---- 插入顺序: [var1, val, var2..varN, key, key2..] ---- var insertOrder = new List<(int Item, string Name, byte[] Path)>(); diff --git a/src/ImeWlConverterCoreTest/GboardBinaryTest.cs b/src/ImeWlConverterCoreTest/GboardBinaryTest.cs index 1808aecc..691103cf 100644 --- a/src/ImeWlConverterCoreTest/GboardBinaryTest.cs +++ b/src/ImeWlConverterCoreTest/GboardBinaryTest.cs @@ -16,6 +16,8 @@ */ using System.IO; +using System; +using System.Collections.Generic; using System.Linq; using ImeWlConverter.Abstractions.Enums; using ImeWlConverter.Abstractions.Models; @@ -104,4 +106,64 @@ public void Export_AllEntriesWithoutPinyin_Throws() Assert.Throws( () => _exporter.ExportAsync(entries, ms).GetAwaiter().GetResult()); } + + /// + /// KEY 的 F1 是「用户选中次数」,必须保持在 Gboard 的量级(官方 1~140)。 + /// 早期版本按名次摊成 1..n 的大跨度,使导入词的 F1 远高于 Gboard 之后学到的 + /// 词,候选顺序被永久冻死(实测:亟需 F1=7775 压过反复选中的 继续 F1=33)。 + /// 这里断言 F1 就是词库给的词频,而不是按名次重排的值。 + /// + [Fact] + public void Export_KeepsFrequenciesInGboardScale() + { + var entries = new[] + { + Entry("啊", "a") with { Rank = 1 }, + Entry("爱", "ai") with { Rank = 5 }, + Entry("安", "an") with { Rank = 100 }, + }; + + using var ms = new MemoryStream(); + _exporter.ExportAsync(entries, ms).GetAwaiter().GetResult(); + var f1 = ReadKeyF1(ms.ToArray()); + + Assert.Equal(new[] { 1, 5, 100 }, f1.OrderBy(x => x).ToArray()); + } + + /// + /// 取出所有 KEY 条目的 F1。 + /// FPT2 块 = "FixedPhraseTable\0" + 3B 对齐 + [u32 count] + count×14B, + /// 条目 = [keyNode:u32][valNode:u32][flag<<24 | F1:u32][D:u16]。 + /// + private static List ReadKeyF1(byte[] d) + { + var tag = System.Text.Encoding.ASCII.GetBytes("FixedPhraseTable"); + var i1 = IndexOf(d, tag, 0); + var i2 = IndexOf(d, tag, i1 + 1); + Assert.True(i2 > 0, "找不到 FPT2 块"); + + var count = System.BitConverter.ToUInt32(d, i2 + 20); + var b = i2 + 28; + var result = new List(); + for (var e = 0; e < count; e++) + { + var flagsF1 = System.BitConverter.ToUInt32(d, b + e * 14 + 8); + var flag = flagsF1 >> 24; + if ((flag & 0x40) != 0 && (flag & 0x08) == 0) + result.Add((int)(flagsF1 & 0xFFFFFF)); + } + return result; + } + + private static int IndexOf(byte[] haystack, byte[] needle, int from) + { + for (var i = from; i <= haystack.Length - needle.Length; i++) + { + var ok = true; + for (var j = 0; j < needle.Length; j++) + if (haystack[i + j] != needle[j]) { ok = false; break; } + if (ok) return i; + } + return -1; + } }