From d30d2946e42247a4539a50fce9ed90644f16ad73 Mon Sep 17 00:00:00 2001 From: fanlusky <1730976608@qq.com> Date: Fri, 18 Sep 2026 17:10:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(installer):=20=E6=89=93=E5=8C=85=E6=97=B6?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=B9=B6=E5=AE=89=E8=A3=85=E8=AF=8D=E6=A0=BC?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E6=A8=A1=E5=9E=8B=20sc.lm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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。 安装脚本无需改动,它已按通配收集资源目录。产物与语料不进仓库,只进锁与声明。 --- .github/workflows/release.yml | 10 +++ .gitignore | 6 ++ installer/Invoke-LocalTest.ps1 | 7 ++ installer/Prepare-PackageFiles.ps1 | 16 +++++ installer/README.md | 2 + installer/tests/package-files.ps1 | 12 ++++ language-model/NOTICE.md | 17 +++++ language-model/README.md | 26 ++++++++ language-model/lock.json | 17 +++++ scripts/build-language-model.ps1 | 103 +++++++++++++++++++++++++++++ 10 files changed, 216 insertions(+) create mode 100644 language-model/NOTICE.md create mode 100644 language-model/README.md create mode 100644 language-model/lock.json create mode 100644 scripts/build-language-model.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3a3edf54..4dee85e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore index 659b2879..672e3f88 100644 --- a/.gitignore +++ b/.gitignore @@ -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__/ diff --git a/installer/Invoke-LocalTest.ps1 b/installer/Invoke-LocalTest.ps1 index 23f546c0..34ce6e84 100644 --- a/installer/Invoke-LocalTest.ps1 +++ b/installer/Invoke-LocalTest.ps1 @@ -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') diff --git a/installer/Prepare-PackageFiles.ps1 b/installer/Prepare-PackageFiles.ps1 index 3ffbaf2d..c43b0efb 100644 --- a/installer/Prepare-PackageFiles.ps1 +++ b/installer/Prepare-PackageFiles.ps1 @@ -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,不依赖本机是否已安装输入法。 diff --git a/installer/README.md b/installer/README.md index 180a5f54..585f5ebc 100644 --- a/installer/README.md +++ b/installer/README.md @@ -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。 ## 本地打包路径 diff --git a/installer/tests/package-files.ps1 b/installer/tests/package-files.ps1 index 25a79b63..cc368a06 100644 --- a/installer/tests/package-files.ps1 +++ b/installer/tests/package-files.ps1 @@ -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 } diff --git a/language-model/NOTICE.md b/language-model/NOTICE.md new file mode 100644 index 00000000..e90c0f6a --- /dev/null +++ b/language-model/NOTICE.md @@ -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` 不覆盖也不重新授权上述第三方数据。 diff --git a/language-model/README.md b/language-model/README.md new file mode 100644 index 00000000..f8318a2e --- /dev/null +++ b/language-model/README.md @@ -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`。它进仓库, +因为它是需要评审的声明文本,不是可以重新生成的产物。 diff --git a/language-model/lock.json b/language-model/lock.json new file mode 100644 index 00000000..9e0b2991 --- /dev/null +++ b/language-model/lock.json @@ -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 + } +} diff --git a/scripts/build-language-model.ps1 b/scripts/build-language-model.ps1 new file mode 100644 index 00000000..5ac11d4c --- /dev/null +++ b/scripts/build-language-model.ps1 @@ -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) 字节)"