feat(installer): 打包时生成并安装词格语言模型 sc.lm

sc.lm 此前只能手工投放,重装即失效,发布包里也没有它。这里把它变成一个被钉住、
可复现、自动参与打包的产品输入。

它不走词库那条路:词库 release 由本仓库之外的构建器产出,资产集合和摘要在产品锁
里逐一钉死,加一项要对方先发新版。改为独立钉住上游语料,与 libime 自己的做法一致。

- language-model/lock.json 钉住上游 URL、语料摘要、转换参数和产物摘要。转换是
  确定性的(已在 MSVC 14.44 与 14.50 两套工具链上验证产物摘要一致),所以产物
  摘要可以像下载摘要一样钉死。
- scripts/build-language-model.ps1 下载、校验、转换、再校验,写临时文件后改名,
  中途失败不会留下摘要对不上的 sc.lm。幂等,摘要对得上直接返回。
- 它只 configure engine/ngram 一个目录,不拉 vcpkg 和 SQLite:转换工具只依赖
  kenlm 自己,而打包机上未必装得起引擎的全部依赖。
- Invoke-LocalTest.ps1 打完整包前自动调用它,轻量包本就不带数据文件,不调。
- 打包脚本只断言文件存在,摘要由构建脚本负责,与词库交给 product_lock.py 校验
  的分工一致。缺模型让打包失败而不是跳过:引擎对缺模型是静默降级的,运行期不会
  暴露,打包是唯一拦得住的地方,package-files.ps1 为此加了反向用例。

模型是 LGPL-2.1-or-later 的第三方数据,声明随之安装为 libime-lm-NOTICE.md。
安装脚本无需改动,它已按通配收集资源目录。产物与语料不进仓库,只进锁与声明。
This commit is contained in:
fanlusky committed 2026-09-19 10:08:39 +08:00
1 parent 80b1fc42a1
commit d30d2946e4
10 files changed
+216

No files matched your search

+10
View File
@@ -468,6 +468,16 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bash scripts/ci/download-dictionaries.sh
# The word-lattice language model is the one product input that is neither built from this
# repository's sources nor published in the dictionary release: it is converted here from the
# ARPA corpus pinned in language-model/lock.json. Both the download and the converted result
# are checked against that lock, so this step is reproducible rather than a fetch of whatever
# the upstream file server serves today. It configures engine/ngram alone, so it needs no
# vcpkg and no SQLite on this runner.
- name: Build the word-lattice language model
shell: pwsh
run: ./scripts/build-language-model.ps1
- name: Decide the signing mode
id: signing
shell: pwsh
+6
View File
@@ -16,4 +16,10 @@ log.txt
# this repository, and both the release workflow and a local release rehearsal drop it beside the
# components under this name.
/MetasequoiaImeDict/
# 语言模型:来源锁和授权声明进仓库,下载的语料、转换工具的构建目录和编出来的 sc.lm
# 不进 —— 它们都能由 scripts/build-language-model.ps1 从 lock.json 重新得到。
/language-model/cache/
/language-model/sc.lm
/language-model/sc.lm.incoming
__pycache__/
+7
View File
@@ -57,6 +57,13 @@ try {
if ($LASTEXITCODE -ne 0) { throw "Settings page build failed ($LASTEXITCODE)" }
} finally { Pop-Location }
# 轻量包不带任何数据文件,所以只有完整包需要语言模型。脚本是幂等的:摘要对得上时
# 直接返回,不重下也不重转,因此这里无条件调用不会让日常 test.ps1 变慢。首次运行
# 要下 ~75 MB 并转换一次,之后都是秒回。
if (-not $Light) {
& (Join-Path $repoRoot 'scripts\build-language-model.ps1')
}
& (Join-Path $PSScriptRoot 'Prepare-PackageFiles.ps1') -Light:$Light -IncludeSymbols:$IncludeSymbols `
-RepoRoot $repoRoot -TsfDirectory windows -ServerDirectory server -UiHtmlDirectory ui-html -NoticesDirectory .
& (Join-Path $PSScriptRoot 'Sign-PackageBinaries-Local.ps1')
+16
View File
@@ -9,6 +9,9 @@ param(
[string]$UiHtmlDirectory = 'ui-html',
[string]$HelpCodeDirectory = 'engine/helpcode',
[string]$DictionaryDirectory = 'MetasequoiaImeDict',
# 词格打分用的语言模型不随词库发布下发,而是由 scripts/build-language-model.ps1
# 从钉住的上游语料现场转换到这个目录。
[string]$LanguageModelDirectory = 'language-model',
# THIRD_PARTY_NOTICES.txt used to sit next to the tip's sources. In the consolidated repository
# the notice covers the whole product and lives at the root, one level above windows/, so where
# to read it is no longer answered by where the tip is.
@@ -66,6 +69,8 @@ $japaneseModel = Join-Path $RepoRoot (Join-Path $DictionaryDirectory 'out\dict_j
$japaneseModelLicense = Join-Path $RepoRoot (Join-Path $DictionaryDirectory 'source\mozc_dictionary_oss\README.txt')
$englishDb = Join-Path $RepoRoot (Join-Path $DictionaryDirectory 'out\english.db')
$othersDb = Join-Path $RepoRoot (Join-Path $DictionaryDirectory 'out\others.db')
$languageModel = Join-Path $RepoRoot (Join-Path $LanguageModelDirectory 'sc.lm')
$languageModelNotice = Join-Path $RepoRoot (Join-Path $LanguageModelDirectory 'NOTICE.md')
Assert-PathExists -LiteralPath $RepoRoot -Description '源码仓库根目录'
Assert-PathExists -LiteralPath $serverRelease -Description 'Server Release 输出目录'
@@ -118,6 +123,11 @@ if 'weight' not in names or pk != ['word', 'display']:
throw "英文词库数据库 schema 检查失败:$englishDb"
}
Assert-PathExists -LiteralPath $othersDb -Description '杂项数据库 others.db'
# 摘要校验留在 build-language-model.ps1 里:它既有 lock.json 又刚生成完文件。
# 这里只确认文件在,与 msime.db 一样 —— 词库的摘要也是由 product_lock.py 校验的。
Assert-PathExists -LiteralPath $languageModel `
-Description '词格整句语言模型 sc.lm(用 scripts\build-language-model.ps1 生成)'
Assert-PathExists -LiteralPath $languageModelNotice -Description 'libime 语言模型授权声明 NOTICE.md'
}
$targetAppData = Join-Path $PSScriptRoot 'app_data'
@@ -142,6 +152,12 @@ else {
Copy-Item -LiteralPath $japaneseModelLicense -Destination (Join-Path $targetAppData 'MOZC_DICTIONARY_LICENSE.txt') -Force
Copy-Item -LiteralPath $englishDb -Destination (Join-Path $targetAppData 'english.db') -Force
Copy-Item -LiteralPath $othersDb -Destination (Join-Path $targetAppData 'others.db') -Force
# sc.lm 落在资源目录根下,与 msime.db 同级:engine/contracts/assets 的清单就是这么
# 声明这个资源的路径的,引擎按该清单里的文件名去找。
Copy-Item -LiteralPath $languageModel -Destination (Join-Path $targetAppData 'sc.lm') -Force
# 模型数据是 LGPL-2.1-or-later 的第三方作品,声明必须跟着二进制到用户磁盘上,
# 理由同下面 rime-ice 那段。
Copy-Item -LiteralPath $languageModelNotice -Destination (Join-Path $targetAppData 'libime-lm-NOTICE.md') -Force
$defaultConfigPath = Join-Path $targetAppData 'config.default.toml'
# 出厂配置来自本仓库的 default_config,不依赖本机是否已安装输入法。
+2
View File
@@ -29,6 +29,7 @@ pwsh -File ./Prepare-PackageFiles.ps1 -TargetVersion 1.2.3 -RepoRoot .. `
- **`THIRD_PARTY_NOTICES.txt` 从 `-NoticesDirectory` 指向的目录取,随安装包装到程序目录。** 合仓后这份声明覆盖整个产品、放在仓根,所以它和 `-TsfDirectory` 分成了两个参数。词库主体含 rime-ice(GPL-3.0)内容,其许可要求保留署名,所以这份文件缺失会让打包直接失败,而不是静默跳过
- **`Sign-PackageBinaries-Local.ps1` 和 `Sign-Installer-Local.ps1` 只用于本地验证,CI 不会调用它们。** 它们创建本机自签名证书并尝试写入受信任存储,正式发布走 workflow 里用仓库 secret 中真实证书的签名步骤
- 词库不再从相邻的 `MetasequoiaImeDict` 工作目录取,CI 从产品锁指定的 `dict-*` release 下载并校验 SHA256,再放到脚本期望的位置
- 语言模型 `sc.lm` 不在那个 release 里。它由 `scripts/build-language-model.ps1` 在打包前从 `language-model/lock.json` 钉住的 libime 语料转换出来,下载摘要和产物摘要都要对上。release workflow 里对应的是「Build the word-lattice language model」这一步,缺了它 `Prepare-PackageFiles.ps1` 会直接失败
- `windows-2025` runner 自带 Inno Setup 6.7.1,`Compile-Installer.ps1` 能自己找到 `ISCC.exe`。但它不带 `ChineseSimplified.isl`,`msime_setup.iss` 的 `[Languages]` 段依赖那个文件,所以 workflow 会在编译前按固定 revision 和校验和把它装进去
## 数据目录
@@ -48,6 +49,7 @@ pwsh -File ./Prepare-PackageFiles.ps1 -TargetVersion 1.2.3 -RepoRoot .. `
- Windows SDK(提供 `signtool.exe`)
- 先初始化本仓 submodule,并完成 `windows/`、`server/` 的 Release 编译以及 `ui-html/` 设置页构建。Release 构建必须生成同目录 PDB;打包脚本会拒绝缺少匹配符号的产物(符号是否随包安装是另一回事,由 `-IncludeSymbols` 决定)。
- 在仓库根目录运行 `python scripts/product_lock.py fetch-dictionaries --staging-root .`,下载并验证产品锁中的词库。
- 词格整句打分用的 `sc.lm` 由 `scripts\build-language-model.ps1` 从 `language-model\lock.json` 钉住的上游语料转换而来。`Invoke-LocalTest.ps1` 打完整包前会自动调它,一般不用手工执行;首次运行要下 ~75 MB 并转换一次,之后摘要对得上就直接跳过。
- `engine/helpcode/helpcodes/` 是本仓的目录,普通检出即有,无需旧 HelpCode 仓库,也无需初始化 submodule。
## 本地打包路径
+12
View File
@@ -34,6 +34,8 @@ try {
'MetasequoiaImeDict/out/others.db',
'MetasequoiaImeDict/out/dict_japanese.dat',
'MetasequoiaImeDict/source/mozc_dictionary_oss/README.txt',
'language-model/sc.lm',
'language-model/NOTICE.md',
'ui-html/webview2/shared/runtime.js',
'ui-html/webview2/candwnd/index.html',
'ui-html/webview2/menu/index.html',
@@ -47,6 +49,7 @@ try {
if ($LASTEXITCODE -ne 0) { throw 'Failed to create packaging fixture' }
& (Join-Path $installer 'Prepare-PackageFiles.ps1') -RepoRoot $fixture -TargetVersion '2026.9.1' -IncludeSymbols
foreach ($file in @('app_data/html/webview2/shared/runtime.js', 'app_data/dictionary-manifest.json',
'app_data/sc.lm', 'app_data/libime-lm-NOTICE.md',
'tsf_dll/32/MetasequoiaImeTsf.dll', 'tsf_dll/32/MetasequoiaImeTsf.pdb',
'tsf_dll/64/MetasequoiaImeTsf.dll', 'tsf_dll/64/MetasequoiaImeTsf.pdb',
'server_exe/MetasequoiaImeServer.pdb',
@@ -87,6 +90,15 @@ try {
& (Join-Path $installer 'Prepare-PackageFiles.ps1') -RepoRoot $fixture -TsfDirectory windows -ServerDirectory server -UiHtmlDirectory ui-html -NoticesDirectory . -Light
if ([IO.File]::ReadAllText($database) -ne 'preserved user data') { throw 'Light package replaced dictionary data' }
if (-not (Test-Path (Join-Path $installer 'app_data/html/webview2/shared/runtime.js'))) { throw 'Light package lost shared contracts' }
# 完整包必须带 sc.lm。缺了它引擎是静默降级的:整句候选只是变差,不会报错,所以打包
# 这一步是唯一能挡住它的地方。轻量包本来就不带数据文件,不受影响。
$languageModelFixture = Join-Path $fixture 'language-model/sc.lm'
Remove-Item $languageModelFixture -Force
& (Join-Path $installer 'Prepare-PackageFiles.ps1') -RepoRoot $fixture -TsfDirectory windows -ServerDirectory server -UiHtmlDirectory ui-html -NoticesDirectory . -Light
$rejected = $false
try { & (Join-Path $installer 'Prepare-PackageFiles.ps1') -RepoRoot $fixture } catch { $rejected = $_.Exception.Message -match 'sc\.lm' }
if (-not $rejected) { throw 'Missing language model was accepted' }
[IO.File]::WriteAllText($languageModelFixture, 'fixture')
Remove-Item (Join-Path $fixture 'ui-html/webview2/shared') -Recurse -Force
$rejected = $false
try { & (Join-Path $installer 'Prepare-PackageFiles.ps1') -RepoRoot $fixture -TsfDirectory windows -ServerDirectory server -UiHtmlDirectory ui-html -NoticesDirectory . } catch { $rejected = $true }
+17
View File
@@ -0,0 +1,17 @@
# 语言模型 sc.lm 的来源与许可
本产品安装的 `sc.lm` 是一个中文三元语言模型,用于给输入法的整句候选打分。
它由 libime 发布的语料模型 `lm_sc.arpa` 转换而来,转换不改变模型内容,只是把
ARPA 文本格式压缩成 kenlm 的量化 trie 二进制格式。
- 上游项目:libime — https://github.com/fcitx/libime
- 数据来源:https://download.fcitx-im.org/data/lm_sc.arpa-20260629.tar.zst
- 版权:Copyright (C) 2022~2026 CSSlayer <wengxt@gmail.com>
- 许可:GNU Lesser General Public License, version 2.1 or later (LGPL-2.1-or-later)
LGPL-2.1 的完整正文随本产品一同安装,见 `THIRD_PARTY_NOTICES.txt` 中 kenlm 一节
所附的许可证全文;该正文同样适用于本模型数据。
读取该模型的代码是 kenlm(https://github.com/kpu/kenlm),其来源、裁剪范围与许可
另见 `THIRD_PARTY_NOTICES.txt`。仓库根 `LICENSE` 不覆盖也不重新授权上述第三方数据。
+26
View File
@@ -0,0 +1,26 @@
# 词格整句打分用的语言模型
本目录钉住 `sc.lm` 的来源,并放置生成它所需的声明。`sc.lm` 是一个 kenlm 三元模型,
`engine/ngram` 把它 mmap 起来给词格的整句候选打分;缺了它引擎会退回启发式打分,
整句候选只是变差,不会停止工作(见 `engine/docs/runtime-architecture.md`)。
模型本身不在仓库里,也不随词库发布下发。它由 `scripts/build-language-model.ps1`
从 `lock.json` 钉住的上游 ARPA 现场转换而来:
```powershell
.\scripts\build-language-model.ps1
```
该脚本下载 ARPA 压缩包、校验其 SHA-256、按 `lock.json` 里的参数转换成 `sc.lm`,
再校验产物的 SHA-256。转换是确定性的,所以产物摘要可以像下载摘要一样钉死。
脚本是幂等的:`sc.lm` 已存在且摘要正确时直接返回,不重新下载也不重新转换。
`installer\Invoke-LocalTest.ps1` 在打完整包前会自动调用它,因此日常跑 `test.ps1`
不需要手工执行。
上游与 libime 用的是同一份数据和同一组参数(见 libime 的 `data/CMakeLists.txt`),
这样我们编出来的模型与 fcitx5 发行版里的 `zh_CN.lm` 等价。`lock.json` 的
`build.order` 必须与 `engine/ngram/CMakeLists.txt` 里的 `KENLM_MAX_ORDER` 一致,
否则运行时读不懂编出来的文件。
`NOTICE.md` 是随产品安装的授权声明,安装后名为 `libime-lm-NOTICE.md`。它进仓库,
因为它是需要评审的声明文本,不是可以重新生成的产物。
+17
View File
@@ -0,0 +1,17 @@
{
"schema_version": 1,
"source": {
"url": "https://download.fcitx-im.org/data/lm_sc.arpa-20260629.tar.zst",
"archive_sha256": "06808333b9173e5374cf2cb5afc12d08f5625bf9abb536489cac376fc05f2e7f",
"member": "lm_sc.arpa"
},
"build": {
"order": 3,
"flags": ["-s", "-a", "22", "-q", "4", "trie"]
},
"output": {
"path": "sc.lm",
"sha256": "3588b3942c8fd62e1a6bd3bae8c7cadc0faf928c175b245224ed475862218387",
"bytes": 34736327
}
}
+103
View File
@@ -0,0 +1,103 @@
# 生成词格整句打分用的 sc.lm。
#
# 词库是从钉住的发布下载的,语言模型不是:它的上游是 libime 的 ARPA 语料,我们按
# libime 自己那组参数现场转换。转换是确定性的,所以 language-model\lock.json 既钉
# 下载的摘要,也钉产物的摘要 —— 两头都校验过,本地和 CI 才会得到同一个文件。
#
# 幂等:sc.lm 已在且摘要正确时直接返回。因此 Invoke-LocalTest.ps1 可以在每次打完整
# 包前无条件调用它,日常 test.ps1 不会为此多花时间。
[CmdletBinding()]
param(
[string]$RepoRoot = (Split-Path -Parent $PSScriptRoot),
# 下载的压缩包与解出来的 ARPA 都留在这里。ARPA 有 ~190 MB,保留它是为了改转换参数
# 时不必重下;删掉也只是下次多下一次。
[string]$CacheDirectory,
# 即使摘要已经对上也重新转换一次。改了 lock.json 的 build 参数时用。
[switch]$Force
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$modelDirectory = Join-Path $RepoRoot 'language-model'
$lockPath = Join-Path $modelDirectory 'lock.json'
if (-not (Test-Path -LiteralPath $lockPath)) {
throw "语言模型的来源锁不存在:$lockPath"
}
$lock = Get-Content -LiteralPath $lockPath -Raw | ConvertFrom-Json
if ($lock.schema_version -ne 1) {
throw "不支持的 lock.json schema_version:$($lock.schema_version)"
}
if (-not $CacheDirectory) { $CacheDirectory = Join-Path $modelDirectory 'cache' }
$modelPath = Join-Path $modelDirectory $lock.output.path
function Test-Digest {
param([Parameter(Mandatory)][string]$LiteralPath, [Parameter(Mandatory)][string]$Expected)
if (-not (Test-Path -LiteralPath $LiteralPath)) { return $false }
return (Get-FileHash -Algorithm SHA256 -LiteralPath $LiteralPath).Hash.ToLower() -eq $Expected.ToLower()
}
function Assert-Digest {
param([Parameter(Mandatory)][string]$LiteralPath, [Parameter(Mandatory)][string]$Expected,
[Parameter(Mandatory)][string]$Description)
$actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $LiteralPath).Hash.ToLower()
if ($actual -ne $Expected.ToLower()) {
throw "$Description 的 SHA256 与 lock.json 不符:期望 $($Expected.ToLower()),实得 $actual($LiteralPath)"
}
}
if (-not $Force -and (Test-Digest -LiteralPath $modelPath -Expected $lock.output.sha256)) {
Write-Host "语言模型已就绪:$modelPath"
return
}
New-Item -ItemType Directory -Path $CacheDirectory -Force | Out-Null
$archiveName = Split-Path -Leaf ([uri]$lock.source.url).AbsolutePath
$archivePath = Join-Path $CacheDirectory $archiveName
$arpaPath = Join-Path $CacheDirectory $lock.source.member
if (-not (Test-Digest -LiteralPath $archivePath -Expected $lock.source.archive_sha256)) {
Write-Host "下载语料模型:$($lock.source.url)"
# 摘要没对上的残留必须先删:curl 的断点续传会把两次不同的下载接在一起。
if (Test-Path -LiteralPath $archivePath) { Remove-Item -LiteralPath $archivePath -Force }
curl.exe -L --fail --retry 3 -o $archivePath $lock.source.url
if ($LASTEXITCODE -ne 0) { throw "下载语料模型失败($LASTEXITCODE):$($lock.source.url)" }
}
Assert-Digest -LiteralPath $archivePath -Expected $lock.source.archive_sha256 -Description '语料模型压缩包'
# Windows 自带的 tar 是 bsdtar,直接认 zstd,不需要另装 zstd。
if (Test-Path -LiteralPath $arpaPath) { Remove-Item -LiteralPath $arpaPath -Force }
tar.exe -xf $archivePath -C $CacheDirectory
if ($LASTEXITCODE -ne 0) { throw "解压语料模型失败($LASTEXITCODE):$archivePath" }
if (-not (Test-Path -LiteralPath $arpaPath)) {
throw "压缩包里没有 $($lock.source.member):$archivePath"
}
# 只 configure engine\ngram 这一个目录,不走 engine\CMakeLists.txt。转换工具只依赖
# kenlm 自己(零 Boost、零 SQLite),为它拉起整个引擎的依赖没有意义,在只下载了
# 二进制产物的打包机上还未必装得起来。
$toolBuild = Join-Path $CacheDirectory 'build-tool'
$toolPath = Join-Path $toolBuild 'Release\metasequoia_ime_build_binary.exe'
if (-not (Test-Path -LiteralPath $toolPath)) {
cmake -S (Join-Path $RepoRoot 'engine\ngram') -B $toolBuild -DMETASEQUOIA_IME_BUILD_NGRAM_TOOLS=ON
if ($LASTEXITCODE -ne 0) { throw "配置转换工具失败($LASTEXITCODE)" }
cmake --build $toolBuild --config Release --target metasequoia_ime_build_binary --parallel
if ($LASTEXITCODE -ne 0) { throw "构建转换工具失败($LASTEXITCODE)" }
}
if (-not (Test-Path -LiteralPath $toolPath)) {
# 单配置生成器(Ninja、Makefiles)不套 Release 子目录。
$toolPath = Join-Path $toolBuild 'metasequoia_ime_build_binary.exe'
if (-not (Test-Path -LiteralPath $toolPath)) { throw "没有找到转换工具:$toolBuild" }
}
# 先写到临时文件再改名:转换中途失败不应留下一个摘要对不上的 sc.lm,让下次运行
# 以为只是缓存脏了。
$stagingPath = "$modelPath.incoming"
if (Test-Path -LiteralPath $stagingPath) { Remove-Item -LiteralPath $stagingPath -Force }
& $toolPath @($lock.build.flags) $arpaPath $stagingPath
if ($LASTEXITCODE -ne 0) { throw "转换语言模型失败($LASTEXITCODE)" }
Assert-Digest -LiteralPath $stagingPath -Expected $lock.output.sha256 -Description '语言模型 sc.lm'
Move-Item -LiteralPath $stagingPath -Destination $modelPath -Force
Write-Host "语言模型生成完成:$modelPath($((Get-Item -LiteralPath $modelPath).Length) 字节)"