Skip to content

F1: mixed Chinese/English typing without switching (rule-based) - #2

Merged
lmanchu merged 4 commits into
masterfrom
f1-mixed-script
Sep 11, 2026
Merged

lmanchu merged 4 commits into
masterfrom
f1-mixed-script

Conversation

@lmanchu

@lmanchu lmanchu commented Sep 9, 2026 •

Copy link
Copy Markdown
Owner

P1 of the plan, after three rounds (implementation → fresh-context review → fixes → re-verify → gate fix).

A new engine module Source/Engine/MixedScript/ (Latin lexicon, Bopomofo-shape tracker, decision rules, LatinPassthroughLM) plus hooks in KeyHandler.mm. Letters whose Bopomofo shape is structurally dead stay English (rule A — covers 88.8% of English tokens in the corpus, 0 false positives across every single-syllable reading in data.txt); a dictionary word that is also a valid tone-1 syllable stays Chinese (space is the tone-1 key) with the English form at candidate row 2 and Tab flipping; only words the user has explicitly picked before auto-commit as English on space. Everything is behind MixedScriptEnabled (default off — dogfood with defaults write org.openvanilla.inputmethod.McBopomofo MixedScriptEnabled -bool true) and only active on the standard (大千) layout.

Numbers on the private 200-sentence corpus, through the real KeyHandler (tools/eval/BASELINE.md, "app path"):

OFF ON
English tokens kept (human-typed keys) 0/385 (0.0%) 349/385 (90.6%)
Pure-Chinese char accuracy (same sentences, English removed) 4369/4586 (95.3%) 4369/4586 (95.3%), byte-identical
latency / sentence (Debug, XCTest) 4.9 ms 5.3 ms (CLI -O2: 2.6 ms)

Reviews: docs/REVIEW-P1-2026-09-10.md (7 blocking — the first round's CLI numbers did not describe the app; rule C conflicted with tone-1 typing; 4 upstream tests red) → all closed in round 2; docs/REVERIFY-P1-2026-09-10.md (B1–B7 confirmed closed, mutations 0/6 survived; one new blocking: no keyboard-layout gate, 26-key layouts turned Chinese into raw letters) → closed in round 3 with a test that drives four non-standard layouts ON vs OFF and is red without the gate.

Tests: 160 XCTest (upstream 125 + 35 KeyHandler-level mixed-script tests, incl. testEval200ThroughKeyHandler), 176 engine tests. Not installed on this machine (by decision).

Known non-blocking items carried to P4 (from the re-verify): a lowercase letter dismisses any candidate window (upstream beeps); two spaces after an English word open a candidate window; picking English via Tab is also learned by the UserOverrideModel so MixedScriptLatinOnSpaceForUserWords=false cannot fully switch auto-English off; xcodebuild test rewrites BASELINE.md latency numbers; rule B in practice governs only ~15 dictionary words (docs should say so).

🤖 Generated with Claude Code

lmanchu and others added 4 commits September 10, 2026 01:05
Adds Source/Engine/MixedScript/ (LatinLexicon, BopomofoShapeTracker,
MixedScriptTracker, LatinPassthroughLM) implementing the three-rule
decision from zhuyin-ime-personal.md's P1 design: rule A converts a
structurally-impossible Bopomofo run straight to literal English; rule B
registers a dictionary-word run as a low-scored alternate candidate at its
Chinese reading; rule C promotes that alternate to the active value when
followed by a space/end-of-input. mixedScriptEnabled defaults on and is a
no-op path when off, leaving existing Chinese typing byte-for-byte
unchanged (verified via ctest and eval200 --mixed off).

Wires the engine into KeyHandler.mm (minimal hooks around the existing
BPMF key path, backspace, Esc, Tab/candidate selection), McBopomofoLM
(merges LatinPassthroughLM into unigram dispatch), LanguageModelManager
(loads the bundled + user word lists), and Preferences (mixedScriptEnabled,
mixedScriptLatinOnSpace). Adds a curated tech-term seed list and a
build_lexicon.py generator over macOS's system word list (see
ACKNOWLEDGEMENTS.md for its license/provenance).

Extends the eval harness (mixime-eval --mixed on|off, run_eval.py --mixed)
to drive the same decision engine for parity between the CLI and the app.
On the private eval200 corpus: F1 token retention 0.0% -> 95.6% (target
>=70%), F2 char accuracy unchanged at 95.3% (target >=94.8%), latency
~2.6ms/sentence avg (target <5ms). 32 new gtest cases; 156/156 total pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Round 2 on f1-mixed-script, addressing every blocking finding in
docs/REVIEW-P1-2026-09-10.md. The branch previously cost 5.1 points of
pure-Chinese accuracy, silently swallowed whole sentences, and was on by
default with four upstream tests red.

Rules
- Drop the automatic "dictionary word + space => English" default. On the
  standard layout space *is* the tone-1 key, so that rule and ordinary
  Chinese input are the same gesture: "up " stopped producing 因, "fu/ "
  stopped producing 清, and the non-letter keys inside those syllables
  were dropped outright. A trailing space now promotes a run to English
  only when the word is in the user's own lexicon -- a word they picked by
  hand before. Everything else stays Chinese with the English form as a
  candidate. kMinAmbiguousWordLength 2 -> 3; the preference is renamed
  MixedScriptLatinOnSpaceForUserWords to match what it now controls.
- Score the rule-B Latin alternate just under the reading's top unigram
  instead of a fixed -99, so it is the candidate window's second row and
  the first Tab reaches it (it used to be last of 39-72). Also stop
  registering an alternate for runs that are not dictionary words at all.
- Only register a rule-B alternate as "learnable": an explicit pick now
  has to match an alternate this composition registered, not merely be a
  candidate that happens to be spelled with ASCII letters.

KeyHandler
- A space that ends a Latin run inserts a real space instead of falling
  into the candidate-window branch, where every following letter was
  swallowed ("acer api gmail" committed "acer"). A letter typed into a
  candidate window now cancels it and keeps typing rather than beeping
  the key away, and Enter still commits after flushing a run.
- Esc clears the reading buffer as well as the tracker; backspacing a
  locked run to empty releases the lock; force-commit no longer drops a
  pending run; buildInputtingState is preference-gated like every other
  mixedScript path; a tone key straight after English no longer parks an
  orphan tone mark; charCode is range-checked before being truncated to
  char, so a non-ASCII character whose low byte lands in a-z is not
  treated as that letter and the arrow keys reach the run-boundary branch
  on purpose rather than by coincidence.

Loading and persistence
- The 204k-word list loads on a background queue and is published to the
  key thread when ready (it was adding ~150 ms to every activateServer:).
  A null lexicon simply means the dictionary rules do not apply yet.
- isPrefix()'s index is kept ordered as the lists load instead of being
  sorted lazily on first use, which cost 294 ms on the key thread.
- rememberWord() reports a failed write, creates the user data folder,
  and re-resolves the path so it follows useCustomUserPhraseLocation.

Default and measurement
- mixedScriptEnabled defaults to false; see README for the opt-in, and
  for the note that builds before today wrote `true` into the plist.
- New McBopomofoTests/MixedScriptKeyHandlerTests.swift: 33 tests, one per
  reproduction sequence in the review plus Esc/backspace/arrow-key/
  force-commit/two-words/long-run/Shift coverage, and
  testEval200ThroughKeyHandler, which types the whole corpus into a real
  KeyHandler. That is now the acceptance measurement; tools/eval is
  relabelled engine-only. App path: F1 349/385 = 90.6%, and pure-Chinese
  accuracy 4369/4586 = 95.3% with the feature on -- identical to the
  baseline and to the feature off, i.e. no regression at all.
- Engine tests: 176 pass (was 156), including the two mutations that
  survived the review's mutation run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e is on

Follow-up to the round-2 fixes. The word lists are loaded off the key
thread now, but they were still loaded for everyone: KeyHandler's
initializer asked LanguageModelManager for the lexicon, and
+loadDataModels warmed it unconditionally. With the preference defaulting
to off, that is ~20 MB and a background read nobody asked for. The
tracker now starts with no lexicon and is handed one on the first key
actually handled with mixed typing enabled, which is also what starts the
load. handleForceCommit's new pending-run check is gated on the
preference and input mode for the same reason as every other path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…is not a choice

Re-verify (docs/REVERIFY-P1-2026-09-10.md) found the one remaining blocker:
BopomofoShapeTracker assumes one key -> one Bopomofo component, which is
only true for the standard layout; on 許氏/倚天26 the feature turned whole
Chinese sentences into raw letters (0.3% char accuracy). Every mixedScript
code path now goes through -[KeyHandler _mixedScriptAvailable], which also
requires Preferences.keyboardLayout == standard. Test drives four
non-standard layouts ON vs OFF and requires identical output; verified red
with the gate removed.

R5: a "_latin_" node has a single candidate, so Tab-cycling over it no
longer counts as picking English and no longer appends the word to
latin-user.txt.

160 XCTests, 176 engine tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@lmanchu
lmanchu merged commit 3d7db6f into master Sep 11, 2026
1 of 10 checks passed
@lmanchu
lmanchu deleted the f1-mixed-script branch September 11, 2026 00:08
lmanchu pushed a commit that referenced this pull request Sep 11, 2026
…eval, isolation)

1. Rebuild Source/Data/latin-words.txt entirely from SCOWL/ESDB (size<=70,
   American spelling, variant level <=1) instead of macOS's 1934 web2 word
   list with SCOWL only as a frequency-tier annotation. web2 had no
   inflected forms at all; the new list carries them directly and every
   word now has a rank tier (was ~39%). tools/lexicon/build_lexicon.py
   rewritten to build both the word set and its tiers from a local SCOWL
   repo checkout in one pass; tools/lexicon/scowl-size-tiers.tsv (the old
   annotation-only file) is removed. 141,697 words (was 204,217): 80,067
   added, 142,587 removed, 61,630 kept -- ACKNOWLEDGEMENTS.md and
   README.markdown updated to match. P1 regression: F1 app-path retention
   holds at 349/385 = 90.6%, pure-Chinese ON=OFF still identical character
   for character; a sweep of every tone-1 single-syllable reading in
   data.txt found exactly 2 (of 138 with a key sequence >= 3 letters)
   changed Rule-B ambiguity status ("ful"/ㄑㄧㄠ newly ambiguous,
   "tji"/ㄔㄨㄛ no longer is) -- neither is used by any existing test, and
   Rule B never overrides the default Chinese candidate on its own.

2. Learn from Latin runs the user actually types and commits (Enter/
   space/punctuation), not only from an explicit Tab/candidate-window
   completion accept -- new Preferences.latinLearnTypedWords (default
   on). KeyHandler's _commitMixedScriptLatinRun: gains a mayLearn flag so
   Tab landing there (no completion was on offer) never counts as
   evidence the user meant to finish the word, and
   MixedScriptTracker::latinRunAlreadyRemembered() prevents double-
   counting a run a completion accept already wrote. Esc/Backspace never
   reach the commit path, so a canceled run is never learned.

3. Tab (and the completion tooltip/Shift+Tab candidate window) must not
   grow an already-finished word into a longer dictionary entry sharing
   its prefix -- new KeyHandler _isAlreadyCompleteWord:lexicon: gate
   (word is itself a known word whose rank is at least as good as the
   best longer completion), applied at the pending-run, committed-node,
   and tooltip call sites. "acer" (tech-seed) stays "acer", not
   "acerbic"; "the" stays "the". Rule-B alternates are exempt so e.g.
   "coo" can still complete into "cook" on a second Tab.
   testR5_TabOnARuleARunDoesNotWriteTheUserLexicon restored to its
   original semantics; testTabDoesNotExtendACompleteWord added.

4. LatinCompletionKeyHandlerTests' eval now classifies "never completable"
   tokens into proper-noun/abbreviation, inflected-form, and other, and
   adds a "with history" pass that replays eval200 in corpus order,
   teaching each row's eligible words (mirroring fix #2) before scoring
   the next row. Inflected-form count is 0 in both passes, confirming
   fix #1's dictionary switch. BASELINE.md's P3 section replaced with
   both tables.

5. KeyHandlerBopomofoTests (upstream, mixedScript-unaware) now forces
   mixedScriptEnabled/latinCompletionEnabled/latinLearnTypedWords off in
   setUp and restores the real values in tearDown, so it no longer
   inherits whatever this machine's live plist happens to have (it was
   failing testInvalidBpmf whenever MixedScriptEnabled=1 on disk). Full
   suite verified green with the real pref both true and false, and the
   real plist ends each run unchanged.

6. New testing-only LanguageModelManager.resetLatinLexiconForTesting()
   (and LatinLexicon::reset()) reloads the process-wide Latin lexicon
   fresh before every KeyHandler-level test, fixing the cross-test
   pollution from REVERIFY-P1-2026-09-10.md's R12 (an accepted word in
   one test used to silently change another test's top completion).
   Waits for any load already in flight before resetting to avoid a
   data race with the test host app's own startup load. Word-choice
   workaround comments from the pollution era removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
lmanchu pushed a commit that referenced this pull request Sep 12, 2026
…eval, isolation)

1. Rebuild Source/Data/latin-words.txt entirely from SCOWL/ESDB (size<=70,
   American spelling, variant level <=1) instead of macOS's 1934 web2 word
   list with SCOWL only as a frequency-tier annotation. web2 had no
   inflected forms at all; the new list carries them directly and every
   word now has a rank tier (was ~39%). tools/lexicon/build_lexicon.py
   rewritten to build both the word set and its tiers from a local SCOWL
   repo checkout in one pass; tools/lexicon/scowl-size-tiers.tsv (the old
   annotation-only file) is removed. 141,697 words (was 204,217): 80,067
   added, 142,587 removed, 61,630 kept -- ACKNOWLEDGEMENTS.md and
   README.markdown updated to match. P1 regression: F1 app-path retention
   holds at 349/385 = 90.6%, pure-Chinese ON=OFF still identical character
   for character; a sweep of every tone-1 single-syllable reading in
   data.txt found exactly 2 (of 138 with a key sequence >= 3 letters)
   changed Rule-B ambiguity status ("ful"/ㄑㄧㄠ newly ambiguous,
   "tji"/ㄔㄨㄛ no longer is) -- neither is used by any existing test, and
   Rule B never overrides the default Chinese candidate on its own.

2. Learn from Latin runs the user actually types and commits (Enter/
   space/punctuation), not only from an explicit Tab/candidate-window
   completion accept -- new Preferences.latinLearnTypedWords (default
   on). KeyHandler's _commitMixedScriptLatinRun: gains a mayLearn flag so
   Tab landing there (no completion was on offer) never counts as
   evidence the user meant to finish the word, and
   MixedScriptTracker::latinRunAlreadyRemembered() prevents double-
   counting a run a completion accept already wrote. Esc/Backspace never
   reach the commit path, so a canceled run is never learned.

3. Tab (and the completion tooltip/Shift+Tab candidate window) must not
   grow an already-finished word into a longer dictionary entry sharing
   its prefix -- new KeyHandler _isAlreadyCompleteWord:lexicon: gate
   (word is itself a known word whose rank is at least as good as the
   best longer completion), applied at the pending-run, committed-node,
   and tooltip call sites. "acer" (tech-seed) stays "acer", not
   "acerbic"; "the" stays "the". Rule-B alternates are exempt so e.g.
   "coo" can still complete into "cook" on a second Tab.
   testR5_TabOnARuleARunDoesNotWriteTheUserLexicon restored to its
   original semantics; testTabDoesNotExtendACompleteWord added.

4. LatinCompletionKeyHandlerTests' eval now classifies "never completable"
   tokens into proper-noun/abbreviation, inflected-form, and other, and
   adds a "with history" pass that replays eval200 in corpus order,
   teaching each row's eligible words (mirroring fix #2) before scoring
   the next row. Inflected-form count is 0 in both passes, confirming
   fix #1's dictionary switch. BASELINE.md's P3 section replaced with
   both tables.

5. KeyHandlerBopomofoTests (upstream, mixedScript-unaware) now forces
   mixedScriptEnabled/latinCompletionEnabled/latinLearnTypedWords off in
   setUp and restores the real values in tearDown, so it no longer
   inherits whatever this machine's live plist happens to have (it was
   failing testInvalidBpmf whenever MixedScriptEnabled=1 on disk). Full
   suite verified green with the real pref both true and false, and the
   real plist ends each run unchanged.

6. New testing-only LanguageModelManager.resetLatinLexiconForTesting()
   (and LatinLexicon::reset()) reloads the process-wide Latin lexicon
   fresh before every KeyHandler-level test, fixing the cross-test
   pollution from REVERIFY-P1-2026-09-10.md's R12 (an accepted word in
   one test used to silently change another test's top completion).
   Waits for any load already in flight before resetting to avoid a
   data race with the test host app's own startup load. Word-choice
   workaround comments from the pollution era removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant