第2章:1日でできたプロトタイプ

全体像の中でこの章が扱う部分: 主経路(詳細は付録「全体像」を参照)

最初の開発日、ログには三つの層がすでに並んでいた

最初の開発日に記録されたコミットを並べると、担う役割ごとに三つの層へきれいに分かれます。

主なモジュール
捕捉hook(WH_KEYBOARD_LLフック)、RawKeyEvent/KeyAction(コア型)、scanmap
判定timed-fsm、engine(NICOLA同時打鍵状態機械)、kana_table、ngram
出力output(SendInputキー注入)、ime(TSF+IMM32検出)、platform traits、tray、main

これらは、思いつくままに書き足された名前の羅列ではありません。一つ一つが、入力から 出力までの経路のどこかを担う部品です。最初の開発日には、入力から出力までの最小経路が 動いていました。キーを捕捉する、NICOLA判定を行う、出力する。この三段階が、その日のうちに 一本の線としてつながったということです。

この経路には、書いた順にコードを積んだのではなく、すでに三つの層がありました。NICOLA判定を 行うengineはOSに依存しません。KeyboardHook・KeySender・ImeDetectorという境界がその外側にあり、 実際のWin32呼び出しはさらにその外側、Windows実装だけに閉じていました。判定ロジックと、 OSとのやり取りを、最初から別の場所に置いていたということです。判定と入出力を同じ関数に 書いてしまえば、あとから片方だけを差し替えることはできません。この経路は、その失敗を 避ける形で最初から組まれていました。

キーボードフックには、もう一つ別の理由による制約もありました。Windowsは、フックコールバックが 一定時間以内に処理を返さなければ、そのフックを強制的に解除します。フックの中で時間のかかる 処理を行うこと自体が、そもそも許されていません。hookモジュールの役目は、この制約から逆算して 一つに絞られていました。押下・離上のイベントを拾い、タイムスタンプを付けて、次の層へ渡す だけです。同時打鍵かどうかの判定も、IME状態の操作も、フックコールバックの外側で行われます。 フックは判断する場所ではなく、信号を出すだけの場所として設計されていました。

キーを受け取った後、どう手放すかにも選択肢がありました。一般的なキーボードフックには 二つの動作方式があります。一つは、関係のあるキーだけを横取りし、それ以外はそのまま 素通しするフィルター方式です。もう一つは、すべてのキーをいったん飲み込み、判定を終えて から自分で送り直すリレー方式です。awaseが採ったのは後者でした。フィルター方式は、他の フックソフトウェアとの実行順序次第で競合しうる一方、リレー方式はすべてのキーを一度FIFO キューに通すため、順序の保証が経路全体で一貫します。AutoHotKeyのような他のキーリマッパーと 併用しても入力が届き続けるのは、この設計の副産物です。ただしリレー方式には、それ自体が 生む問題もありました。送り直したキーを、awase自身のフックがもう一度受け取ってしまえば、 無限ループになります。この事故を避けるため、送り直すキーにはdwExtraInfoという Windowsが用意するフィールドに専用の目印(マーカー)を付け、フックの側で「これは自分が 送ったものだ」と判別できるようにしました。この仕組みを持つhook.rsが最初に追加された コミットは、timed-fsmが追加された1秒後、n-gramモデルの土台となるコミットとまったく 同じ2026年3月28日21時17分10秒でした。信号を出すだけに絞ったフックの設計と、その信号を 安全に往復させる仕組みは、同じ瞬間に一つの塊として生まれていたことになります。

信号を出すだけのフック、判断まで抱え込んだフック

この設計判断が的外れではなかったことは、後になって別の角度から裏付けられました。第1章で 触れたやまぶきの実行ファイル解析では、やまぶき4・やまぶきRのいずれも、同時打鍵の判定を フックコールバックの内部で、Sleepを使って同期的に行っていたことが分かっています。

やまぶき4やまぶきRawase
フックが処理をブロックするかする(Sleep)する(Sleep)しない(即return)
IME状態の操作フック内で直接フック内は判定のみ、操作は撤去メッセージループ内で非同期に実行
フックの役目判定と操作の両方判定のみ信号を出すだけ

やまぶきRは、やまぶき4からIME状態を直接書き換える呼び出しを取り除いており、改善の跡は 見えます。しかし同時打鍵判定に使うSleepそのものは、やまぶきRになっても残ったままでした。 問題の一部にだけ気づき、フックの中で時間のかかる処理をするという前提そのものには手を 付けなかった、ということです。awaseがフックの役目を「信号を出すだけ」に絞ったのは、 この前提そのものを最初から避けるためでした。

正しく変換された文字が初めて出力に現れた瞬間、動いたのはこの三段階でした。キーボードフックが 拾った押下イベントは、engineに渡りました。同時打鍵のタイミングが判定され、判定結果は KeySenderを通じて実際のキー入力としてWindowsに渡りました。ひらがなが一文字、画面に 現れました。手元の記録から 確認できるのはここまでで、その日一日をどう過ごしたかは本書の主題ではありません。書けるのは、 その日のうちに、動くプロトタイプ一式がコミットされていた、という結果だけです。

その一式には、判定ロジックだけでなく、Win32のキーボードフック・SendInputによるキー注入・ TSFとIMM32を組み合わせたIME状態検出・システムトレイアイコン・実テキストを流し込む シナリオテストまで含まれていました。最初の開発日から、単なる思いつきの試作ではなく、 すでに実運用を意識した骨格が見えていたということです。動いた、というのは「入力が一往復 した」という意味ではなく、「後から使い続けられる形で一往復した」という意味でした。

この三つの実装が別々に用意されていたのには理由があります。キーボードフックは、特定の アプリケーションだけでなく、他のどのアプリケーションを操作中でも押下を捕捉するために 必要でした。SendInputによる注入は、判定結果を実際のキー入力として下流のアプリケーションに 渡すためのものです。IME状態の検出だけは一つのAPIで済まず、TSFとIMM32という二つの 仕組みを組み合わせる必要がありました。アプリケーションによって、どちらのIME連携方式を 採用しているかが違っていたからです。相手の作りが一様でないことは、最初の開発日から すでに前提として組み込まれていました。

表の「判定」層に並ぶtimed-fsmは、他の三つ(engine・kana_table・ngram)と役割が異なります。 engine・kana_table・ngramはNICOLA固有のロジックを持つ、awase自身のモジュールです。ところが timed-fsmは、NICOLAのことを何も知らない、完全に独立したクレートとして最初から切り出されて いました。

理由は、NICOLAの同時打鍵判定が抱える性質にあります。「Nミリ秒以内に別のキーが来なければ、 単独打鍵として確定する」という判定は、届いた入力だけでは決まりません。「入力が来なかった」 という不在(タイムアウト)も、次の状態を決める材料になります。通常の状態機械は「(いまの状態, 来たイベント)→(次の状態, 動作)」という形で遷移を書きますが、この形には「イベントが来 なかったこと」を表す場所がありません。タイマーそのものを、どこかで管理する必要が出てきます。

タイマーの管理を誰が担うかには、大きく三つの案がありました。状態機械の内部で直接タイマーを 設定する案は、状態機械の中に副作用が入り込み、テストがしにくくなります。呼び出し側が状態 機械の出力を見てタイマーを管理する案は、判定のロジックが呼び出し側と状態機械の二箇所に 分かれてしまいます。採用されたのは三つ目、状態機械が「このタイマーを仕掛けてほしい」という 指示そのものを、判定結果と一緒に値として返す案でした。判定は値を返すだけの純粋な関数のままで、 タイマーの実際の設定は呼び出し側にすべて任せます。

この設計は、NICOLAという具体的な入力方式には依存しません。3月28日21時17分09秒、6776e94 というコミットで、この考え方を体現したtimed-fsmという独立クレートが追加されました。NICOLAの 知識を一切持たない、外部依存ゼロの、汎用のタイムド状態機械フレームワークとしてです。1秒後の 21時17分10秒には、次章で扱うn-gramモデルの土台となるコミット6990a7dが積まれています。 プロトタイプが動いたまさにその日、しかも同じ分の中で、後の章まで使われ続ける二つの汎用部品が 並んで生まれていたことになります。engineは、このtimed-fsmを土台として、NICOLA固有の判定 ロジックを組み立てました。土台の上に積んだ部分をどう整理し直したかは、第3章で扱います。

SendInputで実際に何を送るかにも、選択肢がありました。NICOLA判定の結果は、ひらがな一文字 として確定します。この一文字を下流のアプリケーションへ渡す方法には、大きく三つの案が ありました。一つ目は、IMEをJISかな入力モードに切り替え、JISかな配列のキーボードが打つのと 同じVKキーコードを送る案です。二つ目は、ひらがなをローマ字へ逆変換し、ローマ字入力モードの ままローマ字のVKキーコードを送る案です。三つ目は、IMEを経由せず、確定済みのひらがな文字 そのものをUnicode文字として直接送り込む案です。

採用されたのは二つ目、ローマ字のVKキーコードを送る案でした(kana_table.rsが、ひらがな→ ローマ字の逆引きテーブルを備えているのは、この判断の結果です)。理由は、残る二つの案が どちらも同じ弱点を抱えていたことにあります。JISかな入力は、ひらがな一文字ごとの入力こそ キー一つに対応しますが、数字や記号の入力になるとUnicode文字を送らざるを得ませんでした。 そしてUnicode文字による入力には、Chromeとの相性問題がありました。IMEの変換候補の文字列に 入らず、確定済みの生の文字として扱われてしまうことがあったのです。これは、ひらがなを Unicodeとして直接送り込む三つ目の案がそもそも抱えていた問題と、同じ種類のものでした。 ローマ字のVKキーコードであれば、ひらがなも数字も記号も、すべて通常のアルファベット・数字 キーと同じ経路で送れます。Unicode文字への切り替えが必要になる場面自体をなくすことで、 Chromeとの相性問題を構造的に避けたということです。

同じ日のコミットには、やまぶき互換のレイアウトファイルを読み込むyab parserや、文字の 出現頻度に応じて同時打鍵の許容幅を動かすngramモジュールも含まれていました。決め打ちの 閾値一つだけで済ませるのではなく、人によって、あるいは文章によって打鍵の間隔が違うことを 最初から前提にしていたということです。変換ロジックだけを急いで動かし、周辺は後回しに する、という進め方ではありませんでした。

しかし、後から使い続けられる形であることと、使い続けても壊れない形であることは、 別の話です。この章が答えようとしているのは、なぜ「動いた」ことが、完成に近いことを 意味しなかったのか、という問いです。

「動いた」は、まだ何も保証していなかった

文字が正しく入ったことは、うれしい出来事でした。入力から出力まで一往復した以上、あとは 細部を整えるだけだろう、と考えても不思議ではありません。しかし、それは経路が一度、 期待通りの入力に対して動いたという事実でしかありません。同じ意味を持つはずの値が違う型で混在していないか、 確保した資源が確実に解放されるか、判断と副作用が同じ場所に同居していないか。こうした問いには、 まだ何も答えていませんでした。動いたコードは、正しく動く条件を教えてくれません。教えて くれるのは、次にどこが壊れそうか、という手がかりだけです。

最初の開発日から1日後、2日後、3日後にかけて、プロトタイプには次々と構造が足されていきます。 先を見越して足された構造ではなく、動いたコードを読み返した結果、壊れそうな場所に対して 足された構造でした。四つの変更は、いずれも新しい機能を足すためのものではありません。 すでにある経路の、内部の境界を引き直すための変更でした。

型を混ぜないための名前

当時のADRには、次のように問題が整理されていました。

仮想キーコード(VK)とスキャンコードはu16/u32として扱われていた。関数シグネチャ からは引数がVKコードなのか、スキャンコードなのか、タイマーIDなのか区別できなかった。

同じu16という型が、意味の異なる複数の値を同時に表していたということです。関数を呼ぶ側も 書く側も、シグネチャだけを見て引数を取り違える危険を抱えていました。1日後に適用が始まり、 その翌日、2日後にはVkCodeScanCodeというnewtypeが全面的に適用されます(ADR-012)。 newtypeとは、同じデータ型であっても意味が違う値を、別の型として区別するための手法です。 ここでは「VKコードを表す整数」と「タイマーIDを表す整数」を、どちらもu16のまま扱うのでは なく、別の型名を与えることでコンパイラに取り違えを検出させています。 ADR本文には、結果がこう記されています。

VKコードとスキャンコードの取り違えがコンパイル時に検出される。関数シグネチャが 自己文書化(vk: VkCode vs timer_id: usize)。

以後、コード上の取り違えは実行時ではなく、コンパイル時に検出されるようになりました。 バグを直したのではなく、そのバグが起こり得る余地そのものを、型の側から塞いだという 違いがあります。取り違えたまま出荷されていれば、特定のキーだけでタイマーが暴走する ような、再現しにくい不具合になっていたはずです。

後始末を型に任せる

同じ頃、資源の解放をめぐる問題も整理されています。

Win32リソース(キーボードフック、ホットキー、タイマー、トレイアイコン、WinEventフック) は手動でcleanup()内で解放していた。

ここには三つの懸念が併記されていました。uninstall_hook()の呼び忘れ、WinEventHookの ハンドルが保存されずリークすること、そしてパニック時にクリーンアップが保証されないこと です。newtypeの全面適用と同じ2日後、資源ごとにガード型が導入されます(ADR-011)。これは一般に RAII(Resource Acquisition Is Initialization)と呼ばれる手法です。値が作られた瞬間に 必要な資源を確保し、その値が不要になった瞬間、値の破棄(Drop)にあわせて資源を自動的に 解放します。

ガード対象Dropで呼ぶ処理
HookGuardキーボードフックUnhookWindowsHookEx
HotKeyGuardホットキーUnregisterHotKey
TimerGuardタイマーKillTimer
WinEventHookGuardWinEventフックUnhookWinEvent
SystemTray(Drop実装)トレイアイコンShell_NotifyIconW(NIM_DELETE)

解放を呼び忘れるという判断を、人間の注意力に頼らず、値の寿命そのものに委ねたということです。 cleanup()を正しく書けるかどうかは、書いた人の注意力次第でした。ガード型に包んだ後は、 呼び忘れるという選択肢自体が、コードの構造から消えました。呼び忘れが残ったままなら、 フックが解除されないまま複数回登録され、アプリケーションを終了しても入力を奪い続ける ような不具合につながっていたはずです。

「資源の生存期間に解放を結びつける」という考え方自体は、RustやC++に閉じたものでは ありません。Pythonのwith文も、Javaのtry-with-resourcesも、ブロックを抜ける瞬間に 確保した資源(ファイルハンドルやネットワーク接続)を自動的に閉じる、という同じ約束を、 それぞれ別の構文で実現しています。呼び忘れという人間の注意力に頼る部分を、ブロックの 終わりという構造的な合図に置き換える発想は、言語を問わず繰り返し発明されてきました。

判断と実行を分ける

engineの内部はもともと、副作用を宣言的な値として返す作りでした。ADRには次のように 書かれています。

Engine(NICOLA FSM)はtimed-fsmのResponseで副作用を宣言的に記述し、呼び出し側が実行する 「判断と実行の分離」を実現していた。しかしEngineの外側(IMEガード、特殊キー判定、 IME制御)はWin32 API(PostMessageW, ImmSetOpenStatus)を直接呼び出す命令的なスタイル だった。

ここでのFSM(有限状態機械)とは、あらかじめ用意した状態同士が、来た合図に応じて決まった 順序で移り変わる仕組みを指します。信号機が赤・青・黄と決まった順序で切り替わるのと同じ 考え方で、詳しくは第9章で扱います。同じプロトタイプの中に、判断を値として返す流儀と、 その場でOS APIを呼ぶ流儀が同居していたということです。engineの外側で何かを直接呼び出すコードが増えるたびに、どこで副作用が 起きるのかを追う手間が増えていきます。同じ2日後、外側もengineに合わせて書き直されます (ADR-013)。

Engine::on_input(event, ctx) → Decision   (純粋な判断のみ)
AppState::execute_decision(decision)      (副作用の実行はここだけ)

判断を返す層と、副作用を実行する層を、それぞれ一つに絞ったということです。この整理だけで main.rsとAppStateの実装は300行以上短くなったとADRに記録されています。副作用の呼び出し 箇所が一つに絞られたことで、テストは判断だけを対象にすればよくなりました。呼び出し口が 散らばったままなら、キー入力のたびにどのAPIが実際に呼ばれたのかを、テストコードからは 追いきれない状態が続いていたはずです。

ここでいう「判断」は、抽象的な結論ではありません。実際にはEffectという具体的な操作の 列でした。ADRはこの整理を「統一Effectモデル」と呼び、SendKeys(キーを送る)、 SetTimer(タイマーを仕掛ける)、SetImeOpen(IMEのON/OFFを切り替える)といった種類に 分けています。engineの役目は、これらのEffectを自分で実行することではなく、「次にこれを やってほしい」という注文の列を書き出すことだけです。実際にWindows APIを呼ぶのは、 その列を受け取ったexecute_decisionだけでした。

この分担には、前節で触れたフックの制約が関わっています。Windowsは、フックコールバックが 一定時間以内に処理を返さなければ、そのフックを強制的に解除します。ところがSendInputに よるキー送信や、IME状態を切り替えるImmSetOpenStatusのような操作は、実際には数十ミリ秒 かかることがありました。フックの制約時間に対して、無視できない長さです。そこで採られた のが、Effectをその場で実行せず、いったんキューに積んでおくという設計でした。フック コールバックの中では、engineが下した判断をEffectの列として書き出すだけで、実行はしません。 書き出したEffectは、時間の制約を受けないメッセージループ側へ運ばれ、そこで一つずつ 取り出されて初めて実行されます。窓口が注文を受けるだけで、調理は別の場所にある厨房が 担うのと同じ形です。窓口は、料理ができあがるのを待たずに次の客に応対できます。engineと execute_decisionの分離は、この「注文と調理を別の場所で行う」という考え方を、フックと メッセージループという二つの実行タイミングに当てはめたものでした。

この分離は、テストのしやすさにも直結していました。engineが返すのはEffectという値の列で しかないため、実際にWindows APIを呼び出さなくても、「この入力に対して正しいEffectの列が 返ってくるか」だけを確かめれば、判断ロジックを検証できます。本物のキーボードフックを 登録したり、実際にIMEを切り替えたりする環境を用意する必要はありません。

実機やインストール済みのIMEに依存せずに判断ロジックを検証できるということは、変更の たびに実際のWindows環境でキーを打って確かめる手間が要らないということでもあります。 テスト一回あたりが数秒で済むか、実機を用意して手で打鍵して確かめる数分がかかるかの差は、 一日に試せる変更の回数に直接効いてきます。判断を純粋な関数として切り出す設計が、この後の 章でも繰り返し採用される理由は、正しさを保証するためだけではありません。実機での確認を 待たずに次の変更へ進めるという、開発速度そのものへの投資でもありました。

判断を値として書き出し、実行を別の場所に任せるという考え方自体は、awase固有のものでは ありません。似た分担は、他の身近な場面でも見られます。メールソフトで「送信」を押した 瞬間にメールが直接ネットワークへ流れるのではなく、いったん送信トレイに置かれてから、 バックグラウンドの処理が順番に送り出すのも同じ構造です。ソフトウェア設計の分野では、 判断を行う中心部を副作用のない純粋な関数にまとめ、副作用を実行する層でその外側を薄く 覆うという設計が「Functional Core, Imperative Shell」と呼ばれることがあります。判断と 実行を分けるという発想自体に新規性はなく、awaseの工夫は、この発想を300ミリ秒という 具体的な制約を持つWindowsフックへ当てはめた点にありました。

観測と実行を配線し直す

その翌日、3日後には、さらに大きな組み替えが入ります。ADRは当時の状態をこう記していました。

AppStateが「判断ロジック」「OS観測」「副作用実行」「所有と配線」の4つの責務を持って いた。特にon_focus_changed(120行)とrefresh_ime_state_cache(80行)はWin32 API呼び出し と分類ロジックが混在し、テスト不能だった。

一つの構造体が、観測も判断も実行も配線も、同時に抱えていたということです。フォーカス変更を 扱う処理も、IME状態を確認する処理も、Win32呼び出しと分類の判断が同じ関数の中に混ざり、 動作を個別に確かめることができませんでした。ここでObserver・Engine・DecisionExecutor・ Runtimeという四つの層に分け直されます(ADR-014)。観測する場所と、判断する場所と、実行 する場所を、それぞれ別の場所に置き直したということです。判断と実行を分けた2日後の変更に 続けて、翌日には観測までもが別の層として独立したことになります。分けないままなら、 フォーカス変更のたびに観測・判断・副作用が一つの関数の中で絡み合い、どこに不具合が 仕込まれているのかを特定できない状態が続いていたはずです。

こうして、プロトタイプが動いた後、最初に壊れそうな場所へ、順に構造が足されていきました。 型を分け、資源の寿命を型に委ね、判断と実行を分け、観測と実行を分ける。どれも、内部の 責務が混ざっていた場所を一つずつ切り分ける作業でした。「1日で動いた」ことは、この作業を 免除してはくれなかったということです。動いた直後の数日は、新しい機能を追加した日々では なく、動いたものをもう一度読み直し、境界を引き直した日々でした。

四つの変更に共通していたのは、「たまたま今回は大丈夫だった」という状態を、そのままには しなかったという姿勢です。型が一致していたのはたまたまで、資源の解放もたまたま忘れずに 書けていたにすぎません。副作用の呼び出し口はたまたま数が少なく、責務の混在は、たまたま これまで表面化していなかっただけでした。動いた経路を、動いた理由の側から一つずつ 点検し直した数日だったと言えます。

この経路は、内部の型と責務をきれいに分け直しました。ですが、まだ一度も試されていない 前提が残っていました。観測そのもの、つまりWindowsから返ってくる情報が、いつも正しいとは 限らないという前提です。engineは純粋になり、責務は分かれました。しかし、その先で受け取る 観測が信頼できるかどうかは、まだ誰も疑っていませんでした。Observerが取得するIME状態や フォーカス情報は、OSから返ってきた値をそのまま信じる作りのままでした。その値が届いた 時点と、その値を使って何かを実行する時点がずれていないかは、まだ誰も確かめていません でした。


設計原則

動くコードよりも先に境界を引いておくと、後から足す構造が書き直しにならない。

適用条件: プロトタイプが一度動き、まだ規模が小さく、境界を引き直す手間が低いうちに 適用する。動いた直後は、次に壊れる場所を最も安く観察できる時期でもある。

実装の形: 同じ基本型に複数の意味が乗っている値は、newtypeで意味ごとに分ける。確保と 解放が対になる資源は、ガード型に包んで寿命を型に委ねる。判断を返す層と副作用を実行する層を それぞれ一つに絞る。観測・判断・実行・配線という異なる責務は、別の場所に置く。

保証しない範囲: この構造が防ぐのは、内部の責務が混ざることだけである。外部から届く 情報そのものが正しいかどうか、観測が信頼できるかどうかは、この構造だけでは何も保証しない。