Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ codecov_comment.md
DerivedData
build-engine

# P0.5 eval harness: Lman's own text must never enter this repo. Any file
# matching *.private.* (corpus candidates, generated eval sets derived from
# personal vault/Slack text) stays under ~/Dev/mixime-private/ only.
# P0.5 eval harness: nobody's personal text may enter this repo. Corpus
# candidates and the eval TSVs generated from them are derived from private
# notes/chat logs, so keep them outside the repo (point BOPOMIX_EVAL_CORPUS
# at them) or under the ignored eval-corpus/ directory.
# See tools/eval/README.md.
*.private.*
*.private.*
eval-corpus/
3 changes: 1 addition & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ Bopomix (混打注音輸入法) is a fork of OpenVanilla McBopomofo (小麥注
**Runtime:** macOS 10.15 (Catalina) or later

**Development:**
- macOS 14.7 or later
- Xcode 15.3 or later
- macOS 26 or later, Xcode 26 or later (Swift 6.3) -- what the maintainer builds with; upstream McBopomofo's stated floor was macOS 14.7 / Xcode 15.3 and has not been re-verified for this fork
- Python 3.9 (for dictionary data generation)

## Building and Running
Expand Down
4 changes: 2 additions & 2 deletions BopomixTests/KeyHandlerBopomofoTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ class KeyHandlerBopomofoTests: XCTestCase {

var handler = KeyHandler()
// P3 fix #5 (XCTest/machine-preference isolation, see
// ~/.claude/plans/zhuyin-ime-personal.md's P3 fix #5): these upstream
// the design notes' P3 fix #5): these upstream
// tests know nothing about mixedScript and never save/restore its
// preferences themselves, so on a machine that already has
// MixedScriptEnabled=1 in its real plist (Lman dogfoods a master
// MixedScriptEnabled=1 in its real plist (the maintainer dogfoods a master
// build) they would otherwise inherit that value and see mixedScript
// rewrite readings it never used to (e.g. testInvalidBpmf's "ni4"
// becomes a Rule-A literal run instead of the upstream
Expand Down
27 changes: 19 additions & 8 deletions BopomixTests/LatinCompletionKeyHandlerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import XCTest
@testable import Bopomix

/// KeyHandler-level integration tests for P3 English prediction + Tab
/// completion (see ~/.claude/plans/zhuyin-ime-personal.md's F3 scope).
/// completion (see the design notes' F3 scope).
/// Mirrors MixedScriptKeyHandlerTests' approach (drive the real KeyHandler
/// FSM, not the engine in isolation) for the same reason P1's review found:
/// every interesting interaction here is with candidate-window/Tab/grid
Expand Down Expand Up @@ -353,7 +353,7 @@ class LatinCompletionKeyHandlerTests: XCTestCase {
XCTAssertNil(predictedCompletion, "\(state)")
}

/// P3 fix #3 (see ~/.claude/plans/zhuyin-ime-personal.md's P3 fix #3
/// P3 fix #3 (see the design notes' P3 fix #3
/// and KeyHandler's _offeredCompletionFor:lexicon:): once a pending run is
/// itself already a recognized word ranked at least as well as the
/// best longer completion sharing its prefix, that counts as "the
Expand Down Expand Up @@ -1503,9 +1503,11 @@ class LatinCompletionKeyHandlerTests: XCTestCase {
}

/// How many letters of an English token does the user actually have
/// to type before Tab would complete it, on eval200 (the only
/// Traditional-Mandarin-plus-real-project-vocabulary corpus this repo
/// has) -- measured two ways (P3 fix #4): "no history" evaluates
/// to type before Tab would complete it, on the corpus named by
/// `BOPOMIX_EVAL_CORPUS` (a Traditional-Mandarin-plus-real-project-
/// vocabulary TSV, kept outside this repo -- build one from your own
/// text with tools/eval/build_corpus.py) -- measured two ways
/// (P3 fix #4): "no history" evaluates
/// every token cold, against just the built-in dictionary/tech seed;
/// "with history" replays the corpus in row order, and after
/// evaluating each row's tokens, types and commits every eligible one
Expand All @@ -1514,14 +1516,23 @@ class LatinCompletionKeyHandlerTests: XCTestCase {
/// enough to be confirmed becomes more completable later in the
/// corpus. This is a measurement/report, not a
/// correctness gate (except the pure-Chinese control below): skipped
/// outright when the private corpus is not present, and neither table
/// outright when `BOPOMIX_EVAL_CORPUS` is unset, and neither table
/// asserts thresholds the way testEval200ThroughKeyHandler does --
/// there is no prior baseline to hold either to yet, this run creates
/// one for both.
func testEval200LatinCompletion() throws {
let corpusPath = NSHomeDirectory() + "/Dev/mixime-private/eval200.tsv"
guard let corpusPath = ProcessInfo.processInfo.environment["BOPOMIX_EVAL_CORPUS"],
!corpusPath.isEmpty
else {
throw XCTSkip(
"BOPOMIX_EVAL_CORPUS is not set; nothing to measure. Point it at a "
+ "corpus TSV built from your own text with tools/eval/build_corpus.py.")
}
guard FileManager.default.fileExists(atPath: corpusPath) else {
throw XCTSkip("corpus not present at \(corpusPath); nothing to measure")
throw XCTSkip(
"BOPOMIX_EVAL_CORPUS points at \(corpusPath), which does not exist; "
+ "nothing to measure. Build a corpus TSV from your own text with "
+ "tools/eval/build_corpus.py.")
}
let rows = try loadEvalRows(at: corpusPath)
XCTAssertFalse(rows.isEmpty)
Expand Down
20 changes: 14 additions & 6 deletions BopomixTests/MixedScriptKeyHandlerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import XCTest
@testable import Bopomix

/// KeyHandler-level integration tests for P1 zh/en mixed typing (see
/// ~/.claude/plans/zhuyin-ime-personal.md). The engine-level tests under
/// the design notes). The engine-level tests under
/// `Source/Engine/MixedScript/` cover the rules in isolation; everything
/// here drives the real `KeyHandler` FSM the way the input method does,
/// because every blocking defect found in the first review round
Expand Down Expand Up @@ -791,10 +791,18 @@ class MixedScriptKeyHandlerTests: XCTestCase {
/// dropping a whole run). This types the same corpus through the real
/// `KeyHandler` instead and is the number that counts.
func testEval200ThroughKeyHandler() throws {
let corpusPath =
NSHomeDirectory() + "/Dev/mixime-private/eval200.tsv"
guard let corpusPath = ProcessInfo.processInfo.environment["BOPOMIX_EVAL_CORPUS"],
!corpusPath.isEmpty
else {
throw XCTSkip(
"BOPOMIX_EVAL_CORPUS is not set; nothing to measure. Point it at a "
+ "corpus TSV built from your own text with tools/eval/build_corpus.py.")
}
guard FileManager.default.fileExists(atPath: corpusPath) else {
throw XCTSkip("corpus not present at \(corpusPath); nothing to measure")
throw XCTSkip(
"BOPOMIX_EVAL_CORPUS points at \(corpusPath), which does not exist; "
+ "nothing to measure. Build a corpus TSV from your own text with "
+ "tools/eval/build_corpus.py.")
}
let rows = try loadCorpus(at: corpusPath)
XCTAssertFalse(rows.isEmpty)
Expand Down Expand Up @@ -946,7 +954,7 @@ class MixedScriptKeyHandlerTests: XCTestCase {
/// R5: a rule-A run has a single candidate. Tab-cycling over it is not a
/// choice between Chinese and English and must not touch latin-user.txt.
///
/// P3 (see ~/.claude/plans/zhuyin-ime-personal.md's F3 scope) gives Tab
/// P3 (see the design notes' F3 scope) gives Tab
/// a new, deliberate job on a pending rule-A run -- completing it into
/// a longer dictionary word -- but P3 fix #3 also gives it a deliberate
/// *non*-job here: "acer" is tech-seed ranked ahead of every longer
Expand All @@ -973,7 +981,7 @@ class MixedScriptKeyHandlerTests: XCTestCase {
XCTAssertEqual(after, before, "Tab over a rule-A run must not remember the word")
}

/// P3 fix #3 (see ~/.claude/plans/zhuyin-ime-personal.md's P3 fix #3
/// P3 fix #3 (see the design notes' P3 fix #3
/// and KeyHandler's _offeredCompletionFor:lexicon:): Tab must not silently
/// grow an already-finished word into a longer dictionary entry just
/// because one happens to share its prefix. Companion to
Expand Down
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Changelog

混打注音相對於上游小麥注音(McBopomofo)的變更。上游本身的版本紀錄見 [McBopomofo releases](https://github.com/openvanilla/McBopomofo/releases)。

## 0.1.0(2026-09-14)

第一個公開版。基底是 McBopomofo `f5ba010`(2026-09)。

### 新增

**中英混打,不切換輸入法**(`MixedScriptEnabled`,預設開)

- 字母序列一旦拼不出合法注音(例如 `th`,或 `acer` 打到第二個字母),整段轉成英文,組字區直接顯示原始字母;英文後面的空白鍵就是真的空白。
- 拼得出注音、又剛好是英文單字的序列(`app`、`ell`、`all`、`full`)維持中文,英文形式放在候選窗第二列,按一次 Tab 切過去。用 Tab 或候選窗選過的英文詞會寫進個人詞庫,之後同一個詞加空白就直接判成英文(`MixedScriptLatinOnSpaceForUserWords`,預設開)。
- 永不靜默丟字:任何情況下組字區都顯示你打的原始字母。
- 只在標準(大千)配置與「注音」模式下作用;關掉時所有程式路徑短路,行為與上游完全相同(有測試釘住)。
- 數字(維護者自己 200 句中英混打語料,走 app 的真實按鍵路徑):英文 token 保留率 0% → 90.6%,純中文句子與上游逐字元相同(95.3% 同音字正確率不變);混打開與關的每句延遲差異約 0.3 ms(`tools/eval/BASELINE.md` 純中文 on/off 對照)。

**英文預測與 Tab 補全**(`LatinCompletionEnabled`,預設開;需要混打也開著)

- 英文打到 4 個字母,組字區下方提示完整的字加 ⇥;Tab 接受(游標停在字尾,可以繼續打),Shift+Tab 開候選窗列出多個,用平常的選字鍵挑;候選窗開著繼續打字母會更新清單,Esc 回到原始字母。
- 4 個字母的門檻是實測來的:用 17 個每天會打的詞共 100 個按鍵測,門檻 2 時螢幕上出現 33 次錯的提示、只省 7 個按鍵;門檻 4 錯誤提示降到 11 次、省下的按鍵只少 1 個。Tab 與 Shift+Tab 從 2 個字母起就能用,只是不主動提示。
- 已經是完整的字、而且常用度不比更長的補全差時(`acer` 相對 `acerbic`),視為打完了,不提示也不補。
- 排序:個人詞庫(用得多的優先)> 手工科技詞表 > 基礎詞典(依常用度分級)> 字母序。基礎詞典來自 SCOWL/ESDB size ≤ 70 的美式拼法詞表,141,697 個詞、含變化形,分級是 5 個粗桶。
- 數字(同一份語料):英文 token 在 4 個字母內可補全 23%(有個人詞庫歷史時 28.8%),每個 token 省 0.39 到 0.46 個按鍵。不可補全的 47% 裡,81 個是排序沒排到、55 個不在詞典、18 個還沒被判成英文、32 個低於提示門檻。

**從打過的英文學習**(`LatinLearnTypedWords`,預設開)

- 打完一段純英文並以 Enter、空白或標點提交,就計入個人詞庫,不只 Tab 接受的才算。
- 一次提交加 1 分,2 分才影響排序與建議;詞典裡沒有的字(人名、產品名)第一次以 1 分寫入但不會被建議,第二次提交才會。兩次不必在同一次開機內。
- 只記提交出去的英文,Esc 或 Backspace 取消的不記,注音與中文從不記錄。Shift 強制大寫的路徑(上游既有)也不記。
- 寫入採先讀現有檔再合併、暫存檔改名落盤;換使用者詞庫資料夾時一起重新載入,另一台機器同步回來的 `latin-user.txt` 不會被蓋掉。
- 檔案:`~/Library/Application Support/Bopomix/latin-user.txt`,格式 `詞<TAB>次數`,沒有次數的行視為 1。手動刪掉的字要重啟輸入法才會忘記。

**改名為 Bopomix 混打注音,並自動接手小麥注音的資料**

- Bundle id `io.github.lmanchu.inputmethod.bopomix`(macOS 要求輸入法的 bundle id 含 `inputmethod` 這一段,否則註冊會被靜默忽略)。
- 第一次啟動時把 `org.openvanilla.inputmethod.McBopomofo` 的偏好複製到新身分(只補沒有的鍵;自訂詞庫位置與指向舊 app 的 hook 路徑除外),並把 `~/Library/Application Support/McBopomofo/` 逐行合併到 `Bopomix/`(缺的檔複製,其餘只補目的地沒有的行;`latin-user.txt` 以字為單位合併並保留較大的次數)。舊資料只讀不寫,新資料不覆蓋,可以重試。失敗或自訂位置暫時無法讀取時下次啟動再試,並顯示一次通知。
- 引擎的 C++ 命名空間、`McBopomofoLM` 與所有上游檔頭刻意保留,方便跟上游同步。

### 開發與測試

- 引擎的 headless 評測工具 `bopomix-eval`(cmake),可以不裝輸入法就量測「按鍵序列 → 組句」。語料由 `tools/eval/build_corpus.py` 從你自己的文字產生,不隨 repo 附上。
- XCTest 走真實的 `KeyHandler`,評測數字以 app 路徑為準(CLI 的數字曾經誤導過一輪)。
- 測試不得碰真實的偏好與使用者詞庫資料夾:`tools/eval/check_plist_unchanged.sh` 把整套測試包起來跑,前後比對零差異;測試套件必須序列執行。
- 每一輪實作都有獨立的 fresh-context 審查與複驗記錄,原樣放在 `docs/`。

### 已知問題

- 拼得出注音的英文字(`mac` = ㄇㄚ)第一次一定要按 Tab;`ai`、`api`、`app`、`agent`、`acer`、`chat`、`check`、`draft`、`free` 這類高頻詞都屬此類。
- 補全候選窗前三名之外是字母序(詞典只有 5 個常用度分桶)。下一步是換成真實詞頻。
- 補全候選窗開著時按小寫字母會關窗。
- 英文後連按兩個空白會跳出空白的候選窗。
- 許氏、倚天 26 配置沒有混打。
- 整句 AI 選字(本機小模型重排候選)研究過但未實作。
67 changes: 67 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# 參與混打注音 / Contributing to Bopomix

問題回報、想法、PR 都歡迎。這份文件講怎麼把專案建起來、怎麼跑測試、PR 需要附什麼。
細節(架構、狀態機、詞庫資料)在 [AGENTS.md](AGENTS.md) 與 [algorithm.md](algorithm.md),
那兩份也是給 AI 助手讀的,內容以它們為準。

## 建置

需要 macOS 26、Xcode 26(Swift 6.3),引擎測試另外需要 CMake 與 GoogleTest(`brew install cmake googletest`)。

```sh
git clone https://github.com/lmanchu/bopomix.git
cd bopomix
xcodebuild -project Bopomix.xcodeproj -scheme BopomixInstaller -configuration Debug build
```

建好的 `BopomixInstaller.app` 執行一次就會把輸入法裝進 `~/Library/Input Methods/` 並重啟它。
第一次要到系統設定 ▸ 鍵盤 ▸ 輸入方式加入「混打注音」(英文介面顯示 Bopomix)。
之後每次改完程式重跑安裝程式即可;macOS 對同一次登入能 kill 輸入法的次數有限制,
裝了幾次沒反應就登出再登入。

## 跑測試

兩套:Swift 測試(走真正的 `KeyHandler`)與 C++ 引擎測試。

```sh
tools/eval/check_plist_unchanged.sh \
xcodebuild -project Bopomix.xcodeproj -scheme Bopomix \
-configuration Debug -derivedDataPath build \
CODE_SIGN_IDENTITY="-" CODE_SIGNING_REQUIRED=NO DEVELOPMENT_TEAM="" test
```

```sh
cmake -S Source/Engine -B build-engine -DENABLE_TEST=ON && cmake --build build-engine && ctest --test-dir build-engine
```

三件事一定要照做:

- **Swift 測試必須序列跑。** 不要加 `-parallel-testing-enabled YES`。偏好設定是一個所有 runner 共用的檔案,並行會互相踩到,出現與你的修改無關的失敗。
- **測試不得碰你自己的輸入法設定與詞庫。** 上面那個 `check_plist_unchanged.sh` 把測試包起來跑,前後比對 `io.github.lmanchu.inputmethod.bopomix` 這個 domain 與 `~/Library/Application Support/Bopomix/`,有任何差異就報 `PLIST_CHANGED` / `DATA_FOLDER_CHANGED`。新測試若動到偏好,`setUpWithError()` 第一句要 `PreferenceSandbox.install(on: self)`;動到詞庫資料夾要用 `LanguageModelManager.dataFolderOverrideForTesting`。這條規則是血淚換來的:曾經有測試把暫存資料夾的路徑寫進維護者正在用的輸入法。
- **測試跑的時候不要在已安裝的混打注音裡改設定。** 跑完 teardown 會整包還原到測試開始時的快照。

## 評測語料

兩個評測測試(`testEval200ThroughKeyHandler`、`testEval200LatinCompletion`)讀環境變數 `BOPOMIX_EVAL_CORPUS`
指向的 TSV;沒設就 skip。語料是用你**自己的**文字產生的,永遠不進 repo(`.gitignore` 已擋 `eval-corpus/` 與 `*.private.*`):

```sh
python3 tools/eval/build_corpus.py --candidates <你的句子檔> --output eval-corpus/corpus.tsv --cli build-engine/tools/eval/bopomix-eval --data build/Build/Products/Debug/Bopomix.app/Contents/Resources
BOPOMIX_EVAL_CORPUS="$PWD/eval-corpus/corpus.tsv" tools/eval/check_plist_unchanged.sh xcodebuild ... test
```

用法細節與 headless 的 `bopomix-eval` CLI 見 [tools/eval/README.md](tools/eval/README.md)。
評測數字以 XCTest 走真實 `KeyHandler` 的那份為準,CLI 的數字只量引擎;兩者曾經差過一輪,別再被騙。

## 送 PR

- Commit 用 Conventional Commits(`feat(mixedscript): …`、`fix(completion): …`)。
- 動到中英判斷、補全、學習的 PR,請附 eval 前後的數字(`tools/eval/BASELINE.md` 那幾行)和你用的語料規模。
- 改動集中在新檔案與少量插入點。引擎的 `McBopomofo` 命名空間、`McBopomofoLM`、上游檔頭都刻意保留,方便跟上游同步;不要順手改名。
- 打錯字的句子是最好的回報:Issue 裡貼你打的按鍵序列(例如 `ji3slack cj04`)、你期望看到的、實際看到的。

## 我們怎麼做事

這個專案是「人跟 AI 一起 build」的練習:每一輪實作都有獨立的審查與複驗,記錄原樣放在 [docs/](docs/)。
三個從那裡學到、寫進規則的教訓:評測要走 app 的真實路徑;測試不得碰真實設定;
任何「其實不需要做」的判斷都要查證,審查者的建議也要被複驗。
Loading
Loading