はじめに:1日で動いた。しかし、それで終わりではなかった
キーを正しい順序で送りました。IMEの状態も、直前に確認していました。それでも、最初の一文字だけが変換されず、ローマ字のまま入力欄に残りました。
最初に疑ったのは、キー配列を変換するロジックそのものでした。しかし、ログを追っても、変換表にもタイミング判定にも誤りは見つかりませんでした。おかしいのはロジックの中身ではなく、ロジックが前提にしていた「いまIMEはこういう状態のはずだ」という思い込みの方でした。
さらに厄介だったのは、同じ手順を繰り返しても、必ず再現するわけではなかったことです。ある回では正しく変換され、次の回では最初の一文字だけが化けます。原因が入力の順序になければ、残るのは「そのとき何が起きていたかを、こちらが正しく把握できていなかった」という可能性でした。この種の不具合は一度きりではなく、似た症状が違う原因で何度も形を変えて現れました。
これは、Windows用の日本語入力エンジン「awase」(NICOLA親指シフト方式)を開発しているときに起きた出来事です。awaseは著者が個人で、千六百を超えるコミットをかけて作り続けているソフトウェアです。最初の動くプロトタイプは、開発を始めたその日のうちに完成しました。しかし、その後の道のりは、1日で動いたことからは想像できないほど長いものになりました。
本書は、その長い道のりで繰り返し出会った「一見同じに見える不具合」を、症状・仮説・実験・発見・設計というひとまとまりの物語として記録したものです。理想的な設計を最初から思いついていた話ではありません。壊れ方を見て、そのつど設計を発見し直してきた記録です。
変換器ではなく、相手のいるソフトウェアだった
awaseは、見た目にはキー配列を別のキー配列へ変換するだけの、単純なフィルタのように見えます。しかし実際には、awase自身が制御できない相手を常に観測し続けるソフトウェアでした。
相手とは、Windowsが管理するIMEの状態であり、フォーカスがどのウィンドウにあるかであり、他のアプリケーションが送ってくる非同期のイベント(こちらの都合を待たずに届く通知)です。これらはどれも、awaseに問い合わせても正直に教えてくれるとは限りません。ときには古い状態を返し、ときには一度返した答えを覆しました。
具体的にはIMEの開閉状態、変換モードの向き、フォーカス中のウィンドウといった情報です。awaseはこれらを直接読み出せる立場になく、断片的な信号から推測するしかありませんでした。推測の元になる信号自体が、遅れて届いたり、順序が入れ替わったりしました。
Windowsは、IMEとやり取りするための仕組みを公式に二つ用意しています。古くからあるIMM32は、IMEのON/OFF状態や変換モードをアプリケーション側から尋ねたり指示したりするためのAPI(プログラム同士が情報をやり取りする窓口)群です。より新しいTSFは、IMEだけでなく手書き入力や音声入力も含め、アプリケーションと入力サービスの間を仲介する仕組みです。アプリによって、IMM32とTSFのどちらを使ってIMEとやり取りしているかは異なります。awaseにとっては、どちらの仕組みを使っても事情は変わりませんでした。仕組みを通じて何らかの値は返ってきます。しかし、その値が「いま」を表しているのか、「少し前」を表しているのかは、仕組み自体からは分かりませんでした。
この構造は、日本語入力という領域に固有のものではありません。協調してくれない外部プロセスを、一方向のキー送信という操作と断片的な観測だけで制御しようとする場面があります。この場面は、GUIの自動化ツールにも、分散システムのクライアントにも、外界をセンサーで認識するロボットにも共通しています。awaseで起きた不具合の多くは、日本語入力の癖ではなく、この共通構造から生まれていました。
この経験から、本書を貫く一つの命題が生まれました。
外の世界を相手にするソフトウェアでは、観測したことと、いま実行してよいことを分けなければならない。
観測は、常に少し過去のものです。実行は、常にいまこの瞬間に対して行われます。この二つを同じ値として扱ったとき、awaseは何度も壊れました。そして、この二つを型として分けたときにだけ、壊れ方が減りました。
観測と実行を分けるまでの記録
本書に登場する事件や数値は、記憶だけを頼りに書き起こしたものではありません。コミット・ADR(設計判断の記録)・当時のコードそのものを一次資料として、そこから再構成しています。
調査の過程で、著者自身の当初の記憶と、記録に残っていた事実がずれている箇所もいくつか見つかりました。そうした箇所は、本文中で「当時のADRにはこう書かれていた」「現在振り返るとこう見える」というように、記録に基づく記述と著者の解釈とを書き分けています。
各章は、症状・仮説・実験・発見・設計という一つの流れで完結させています。前の章で使った道具をそのまま使い回せる場面もあれば、通用しなかった場面もあります。どちらも、そのまま記録しています。
読者がこの本から得られるのは、完成した設計の説明書ではありません。ある不具合がなぜ起きたのか、最初の仮説がなぜ外れたのか、次にどんな実験をしたのか、という試行錯誤の順序そのものです。設計は、その順序の先にしか現れませんでした。
日本語入力エンジンの内部実装に詳しくなくても、本書は読み進められるように書いています。必要な前提知識は、話が必要とする箇所でそのつど説明します。読者に求めているのは、外部システムを相手にするソフトウェアを書いた経験、あるいはこれから書く関心の方です。
なお、この「観測と実行を分ける」という問題は、日本語入力エンジンだけのものではありませんでした。本書の終章では、著者が別に取り組んでいるロボット向けランタイムの設計で、同じ構造の空白に再び出会った経緯を扱います。
まずは、最初の一文字がローマ字のまま残った、あの日の話から始めます。
第1章:なぜ親指シフトを自分で作るのか
親指シフトでは、二つのキーを、ピアノの和音のように、同時に合わせて押します。一方の手で文字キーを押しながら、もう一方の手で親指キーをそっと添えると、単独で押したときとは違う文字が生まれます。キーの位置ではなく、二つのキーが「間に合っているかどうか」が、出てくる文字を決めます。ここに、普通のキーボード配列とは違う難しさがあります。
二つのキーが「間に合う」かどうかで、文字が変わる
一般的なキーマッピングでは、キーと文字は一対一で対応します。Aキーを押せばAが出る、という単純な表があれば十分です。ShiftキーやCtrlキーとの組み合わせも、押している間だけ状態が変わるという意味では、やはり静的な表の延長にあります。
しかしNICOLA方式の親指シフトでは、文字キーと親指キーのあいだの時間差そのものが、変換結果を左右します。近いタイミングで押せば同時打鍵として扱われ、間隔が開けば単独打鍵として扱われます。押しているかどうかという二値の状態だけでは、この判定を表現できません。
つまり実装すべきものは、キーから文字への表ではなく、時間差を読み取る判定です。ここでいうキーボードフックとは、OSが発生させるキー入力を、本来の送り先に届く前に横取りし、自分のプログラムで内容を確認・加工できる仕組みを指します。キーボードフックが受け取れるのは、個々のキーの押下・離上イベントと、そのタイムスタンプだけです。そこから「二つのイベントは同時打鍵と呼べる関係にあるか」を組み立てる必要があります。この判定を具体的にどう組んだかは、後の章で扱います。ここではまず、親指シフトが「配列表」の話ではなく「タイミング」の話だという点だけを押さえておきます。
単純なキーリマッパーの多くは、キーを別のキーへ静的に置き換えることを前提に作られています。その前提に、同時打鍵の判定という要件を後から足すのは、機能追加というより設計の作り直しに近い作業になります。ゼロから実装を選んだ理由をこの一点だけに帰する記録は残っていませんが、現在振り返ると、この構造的な違いは無視できない理由の一つだったと思います。
既存のエミュレータが記録していた「困りごと」
Windows向けの親指シフトエミュレータは、すでにいくつも存在していました。設計文書の冒頭には、次のような目的が書かれています。
Windows 上で動作するキーボード配列エミュレータを Rust で開発する。既存の「やまぶき」「DvorakJ」と同等の機能を持ち、NICOLA(親指シフト)を含む任意のキー配列をエミュレートできる常駐型ツールを目指す。
既存のツールと同じ機能を持つことが、最初の目標として明記されています。参考にしたプロジェクトも同じ文書に列挙されています。kanataからはLLHOOKバックエンドの実装パターン、スキャンコードベースの入出力、チャネルによる入力受付と処理ループの分離、シミュレーションテスト基盤を参考にしたと記録されています。特に、kanataがplatform-known-issues.adocという文書にまとめていたWindowsのLLHOOK固有の既知問題は、後述するガード設計に直接反映されたとあります。やまぶきからはNICOLA配列の挙動仕様と設定体系を、DvorakJからは機能範囲を、それぞれ参考にしたとされています。
自作を選んだ理由は、記録より先に、私自身の使用経験にありました。もともと使っていたのはやまぶきRでした。ところが定期的にプロセスが止まり、そのたびに手動で再起動する必要がありました。Zoomなど一部のアプリとは相性が悪く、うまく動かない場面もありました。そしてもう一つ、Windows向けの親指シフトキーボードソフトで、開発が継続されているものがそもそも見当たらないという事情もありました。使い続けるための選択肢が、実質的に自分で直す以外になかったということです。
なぜ止まりやすかったのか、当時は理由が分かっていませんでした。やまぶきはソースコードが公開されていないクローズドソースのソフトウェアです。本書の執筆にあたり、配布されている実行ファイルの中身(呼び出している外部関数の一覧であるインポートテーブルと、内部に埋め込まれた文字列)を解析にかけたところ、理由の一端が見えてきました。旧版のやまぶき4は、キーボードフックのコールバックの中でIME操作のAPI(ImmSetConversionStatus)を直接呼び出し、同時打鍵かどうかの判定にはSleepを使って、フックのスレッドそのものを一時停止させていました。やまぶきRでは、IME状態を直接書き換える呼び出しは姿を消していましたが、タイミング判定のためのSleepだけは、フックの中に残ったままでした。フックコールバックは、Windowsの仕様上、短時間で処理を返さなければ強制的に解除されます。たとえるなら、フックは玄関先で来客の用件を一瞬だけ確認する係のようなもので、そこで来客を長く引き止めれば、OSはその係ごと持ち場から外してしまいます。Sleepでスレッドを止める処理は、まさにこの「短時間で返す」という前提に反していました。問題の一部にだけ手を入れ、フックの中で時間のかかる処理をするという前提そのものには触れなかった、ということです。
この問題は、awase固有のものでも、Windowsに固有のものでもありません。重い処理をしているタブを開いたまま他の操作をすると、ブラウザ全体が「応答なし」になって固まることがあります。多くのブラウザは、画面の描画やクリックへの反応を、たった一つのスレッド(単一のイベントループ)で順番にさばいており、どこか一箇所が時間をかけすぎると、他のすべての反応が止まってしまうのです。スマートフォンのアプリでも同じ理由で「アプリが応答していません」という警告が出ることがあり、Androidにはこれを検知して必要なら強制終了する仕組み(ANR、Application Not Responding)が組み込まれています。音楽制作用のオーディオインターフェースが一定間隔で必ず呼び出すコールバックも同様で、この中で時間のかかる処理をすると、音がプチプチと途切れる原因になります。呼び出される場所の種類は違っても、「決まった時間内に必ず制御を返す場所」に重い処理を持ち込んではいけないという制約そのものは、GUIのイベントループにも、スマートフォンのOSにも、音声処理にも共通しています。やまぶき4・やまぶきRが同時打鍵の判定という重い処理をフックの中に置き続けていたのは、この共通の制約を破っていたということです。
この個人的な経験は、後日のADR(Architecture Decision Record)にも、より広い形で裏付けられています。2026年3月30日夜のコミット2835bf6「Address all known issues from other thumb shift emulators」には、他の親指シフトエミュレータで報告されていた問題が整理されています。
| 問題 | 他ソフトの状況 | awaseの対応 |
|---|
| フック消失 | 紅皿でPC高負荷時にキーボードがフリーズすると報告されていた | ハートビート監視を置き、10秒無応答で警告を出す |
| 管理者権限プロセス | 紅皿v0.1.3で管理者昇格オプションが追加されていた | トレイメニューに「管理者として再起動」を用意する |
| キーボードレイアウト変更 | 2025年4月のWindows Updateで106→101配列に変わる事例が多数報告されていた | 起動時にレイアウトを確認し、変更イベントも監視する |
| 画面ロック/セッション切替 | やまぶきRでスリープ復帰後に動作しなくなる報告があった | 電源・セッション切替イベントで状態を丸ごと引き直す |
| 修飾キーのスタック残り | 調査した全エミュレータに共通する問題として記録されていた | フォーカス変更時に、キーの押下状態を実際の状態へ同期し直す |
| カタカナ/ひらがなキー | ロック型のキーでキーアップが来ず、親指キーには使えないと記録されていた | 設定を検証し、IMEガードとして扱えるキー数の上限を設ける |
これらは、他ソフトの不具合を非難するための一覧ではありません。「常駐して全キー入力を横取りするツールが、どこで壊れやすいか」を先に洗い出した、いわば失敗のカタログです。プロジェクト開始から2日後というタイミングでこの棚卸しが行われている点も見落とせません。同時打鍵の判定そのものより先に、壊れ方の一覧を作ることが優先されていました。
当時のADRは、これらを「他ソフトが抱えていた不具合」として淡々と整理しているだけで、私がどの瞬間にどう感じたかまでは書き残していません。ただ、やまぶきRで実際に手を止められた経験があったからこそ、この一覧を作る優先順位は自然に決まっていました。欲しかったのは変換の巧妙さそのものより、日常的に使うツールが些細な不具合で入力不能に陥らないという安心感でした。
「awase」という名前と、最初に作ろうとした範囲
このツールには「awase」という名前が付けられました。最初のREADMEには、名前の由来が次のように書かれています。
awase (合わせ) remaps physical keys based on simultaneous keystroke detection — pressing a character key and a thumb key at the same time produces a different character, like playing a chord on a piano.
「合わせ」は、複数のキーを合わせて押すという動作そのものを指す名前です。ピアノの和音を弾く感覚にたとえる表現は、このREADME初版から一貫して使われています。冒頭で述べた「二つのキーを合わせて押す」という説明は、この由来をなぞったものです。技術的な仕組みの名前ではなく、指の動きの名前が選ばれている点は、このツールが最初から使う側の体感を軸に語られていたことを示しています。
この名前には、後からもう一つの理由が加わっています。2026年6月15日、プロジェクトの公式サイト(awase.cc)がリニューアルされ、「名前の由来」という説明が追加されました。「awase」をQWERTYキーボードで打つと、a→w→a→s→eの五文字は、左手の小指→薬指→小指→薬指→中指という順に、外側から内側へ流れるように打鍵できます。同時打鍵を「合わせる」という意味に加えて、キーボード上で指が美しく動く並びでもあった、という遊び心が、README初版から3か月近くたってから明かされたことになります。
最初に作ろうとした範囲は、NICOLA専用のツールではありませんでした。設計文書が掲げていたのは、やまぶきやDvorakJと同等の機能を持ち、NICOLAを含む任意のキー配列をエミュレートできる常駐型ツールです。つまり、親指シフトはこのツールが対応する配列の一つという位置づけで、最初から一般的なキー配列変換の器として構想されていました。同時打鍵の判定はその器の中でも特に難しい一機能という扱いであり、それ自体が目的として単独で切り出されていたわけではありません。
この器の中身をどこまで固く作るかは、まだ何も決まっていませんでした。同時打鍵の判定をどう表現するか、壊れたときに何を優先するか、といった具体的な設計は、これから手を動かしながら決めていくことになります。実際に手を動かし始めてから、最初の壁にぶつかるまでにかかった時間は、ごくわずかでした。
設計原則:正しく変換するより、入力を失わないことを優先する
常駐型の入力ツールにとって、「正しく変換する」ことより「入力を失わない」ことを優先する。
この原則が働くのは、すべてのキー入力を横取りしてから加工して出す、常駐型のツール全般です。加工の巧拙よりも先に、加工そのものが止まったときに何が起こるかを考える必要がある場面に適用されます。
実装の形は三段階です。正常時は配列変換を適用します。異常を検知した時点では、変換を諦めて元の入力をそのまま通します(PassThroughフォールバック)。重大な異常が続く場合は、変換そのものを自動停止し、トレイ通知でユーザーに知らせます。どの段階でも、キーボードから入力そのものが失われることはなく、再開はユーザーの明示的な操作を待ちます。
この原則が保証するのは、不具合時にキーボードが使い物にならなくなる事態を避けることだけです。変換そのものがいつ正しくなるか、同時打鍵の判定がどれだけ的確かという問題には、この原則は答えません。判定を間違えたまま元の入力をそのまま通しても、利用者は文字が化けたことに気づけます。判定を間違えたままキーボードごと沈黙されるより、それは扱いやすい失敗です。この原則が守っているのは正しさではなく、失敗したときに気づける余地であり、正しい判定そのものは、また別の問題として残ります。
第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 | やまぶきR | awase |
|---|
| フックが処理をブロックするか | する(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日後にはVkCodeとScanCodeという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 |
| WinEventHookGuard | WinEventフック | 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で意味ごとに分ける。確保と
解放が対になる資源は、ガード型に包んで寿命を型に委ねる。判断を返す層と副作用を実行する層を
それぞれ一つに絞る。観測・判断・実行・配線という異なる責務は、別の場所に置く。
保証しない範囲: この構造が防ぐのは、内部の責務が混ざることだけである。外部から届く
情報そのものが正しいかどうか、観測が信頼できるかどうかは、この構造だけでは何も保証しない。
第3章:「よかった」が「よがんた」になる

「よかった」と打ったつもりが、画面には「よがんた」という文字列が出ることがありました。
ローマ字入力ではなく、親指キーとの同時打鍵で仮名を決めるNICOLA方式ならではの化け方です。
入力を受け取る側のコードに誤りがあったわけではありません。二つのキーがほぼ同時に押された
とき、その「ほぼ同時」をどちらの文字に結びつけるべきかという判定そのものが、際どい場合には
決めようがなかったのです。
第1章・第2章では、キーを受け取り、NICOLAの規則で判定し、仮名を出力するまでの経路を
組み立てました。この章で扱うのは、その経路の中でもとりわけ小さな、しかし避けようのない
曖昧さです。二つのキーがほぼ同時に届いたとき、どちらを先とみなすべきか。時間だけでは
決め切れないこの問いに、awaseは意外な道具で答えることになります。
三つのキーが並んだとき、親指はどちらと組むか
NICOLA方式の親指シフトでは、文字キーと親指キーをほぼ同時に押すことで一つの仮名を確定
させます。ここで厄介なのは、char1→thumb→char2という順で三つのキーが短時間に並んだ
場合です。親指キーは、直前の文字キー(char1)と組むべきか、直後の文字キー(char2)と
組むべきか。両者は排他的な二つの仮名を生み出すため、どちらか一方に決めなければなりません。
src/engine/timing.rsには、この判定を「3キー仲裁」と呼ぶコメントが残っています。
/// 3キー仲裁: char1→thumb→char2 の並びで、thumb をどちらとペアリングするか。
最初の判定方法は単純でした。char1とthumbの間隔(d1)と、thumbとchar2の間隔(d2)を比べ、
d1のほうが短ければchar1と組む、というものです。人間の指がキーを押す物理的な間隔を、
そのまま判定材料にする発想であり、これ自体は自然な出発点でした。
タイミングだけでは、決まらない場合がある
ところがこの単純な比較には、見過ごされていた偏りがありました。自然な打鍵では、直前の
キーとの間隔(d1)のほうが、直後のキーとの間隔(d2)より短くなりやすいのです。指の運動として
「押してすぐ次を押す」ほうが「押してからやや間を置いて次を押す」より起こりやすいという、
人間の側の癖でした。
この癖のせいで、「か」に続けて右親指(濁点相当のシフト)を押し、その後「ん」を打つと、
d1が短くなりがちなために「か+右親指=が」が選ばれ、続けて「ん」「た」と打った結果が
「がんた」になってしまいます。「よ」「か」「thumb」「っ」「た」という五打鍵のうち、
中央の三打鍵の親指帰属だけが逆に判定され、「よかった」ではなく「よがんた」という、
意味の異なる文字列が出力されていました。
打鍵のたびに毎回起こるわけではありません。d1とd2の差が十分に大きければ、タイミングの
比較だけで問題なく判定できます。問題が起きるのは、二つの間隔が拮抗し、どちらとも言い切れない
ときだけでした。この「際どいときにだけ間違える」という性質が、原因の特定を難しくして
いました。
統計的な文脈を、判定材料に加える
タイミングが拮抗しているなら、タイミング以外の材料で決めるしかありません。ここでawaseが
持ち込んだのが、直前までに確定した文字列という文脈でした。「よ」の後には「かった」が続き
やすく、「よ」の後に「がんた」が続く頻度は低い。この日本語としての自然さを、確率として
持っておけば、タイミングが決め切れない場合の判定材料になります。
src/ngram.rsには、この考え方を実装したNgramModelが定義されています。持っている
データは二つの頻度表(直前1文字から見た二文字の連なりの確率をbigram、直前2文字から
見た三文字の連なりの確率をtrigramと呼びます)と、そこから導いた閾値を何マイクロ秒まで
動かしてよいかという上下限だけです。文字の並びを丸ごと覚えるのではなく、「この二文字
(三文字)がどれだけ自然に連続するか」という一点だけを、あらかじめ数値にしておく設計
でした。
このモデルを最初に実装したコミット6990a7dは、2026年3月28日21時17分のものでした。
awaseの開発が始まったまさにその日、プロトタイプが動いた同じ日のうちに、この統計モデルの
土台はすでに置かれていたことになります。
three_key_pairingの判定フローは、次の三段階でした(timing.rsのコメントより)。
判定フロー:
1. n-gram なし → タイミング比較(d1 < d2 なら char1)
2. タイミング差が大きい(30%マージン超)→ タイミング優先
3. タイミングが接近 → n-gram スコアで判定
図にすると、タイミングの差が大きい間はn-gramへ迂回しないことがはっきりします。

n-gramスコアは、タイミングに取って代わるものではありません。タイミングの差が十分に
大きいときは、これまで通りタイミングだけで判定します。n-gramが呼び出されるのは、
タイミングが際どく、単独では決め切れない場合だけでした。
Wikipediaをひらがなにして、頻度表を作る
このbigram/trigramの頻度データは、どこから来たのでしょうか。data/ngram_hiragana.csv.gzの
ヘッダーには、出所が明記されています。
# Auto-generated hiragana n-gram frequency table
# Source: Japanese Wikipedia (CirrusSearch dump)
# Analyzer: Sudachi.rs (UniDic) + ambiguous reading correction
ARCHITECTURE.mdにも、同じ内容がやや詳しく記されています。日本語版Wikipediaの全文検索用
ダンプ(CirrusSearch dump)を形態素解析し、それぞれの語にひらがなの読みを付与した上で、
複数の読み方がありうる語(曖昧な読み)を補正してから、ひらがなの連なりとしてbigram・trigramの
出現頻度を集計する、という手順です。形態素解析にはSudachi.rsとUniDic辞書が使われました。
日本語の百科事典の本文まるごとを、意味のある単語としてではなく、ひらがなの連鎖という
音の並びとして数え直す。もとは知識を記述するための文章が、ここでは「どの仮名の後に
どの仮名が続きやすいか」という、タイミング判定のための統計値に姿を変えています。
生成過程を実行した具体的なツールも、探してみると見つかりました。build-ngramという、
awase本体とは別の小さなRustプロジェクトです。付属のREADMEには、次のような手順が
記されています。日本語版Wikipediaの全文検索用ダンプをダウンロードし、記事本文から
句点(。)で終わり10文字以上ある文だけを取り出します。表やリストの断片、短すぎる文は
ここで除かれます。取り出した文をSudachi.rs(UniDic辞書、複合語をなるべくまとめる
最長一致モード)で形態素解析し、それぞれの語の読みをカタカナで取得します。ここで
「今日」は次に「は」が続けば「コンニチ」(こんにちは)、それ以外は「キョウ」、
「一日」は次に「中」「に」「で」「の」が続けば「イチニチ」、それ以外は「ツイタチ」
というように、同じ表記でも前後の文脈によって読みが変わる語(多読字)を、ルールベースの
表で補正します。読みが確定したら、カタカナをひらがなへ変換し、文の境界をまたがない形で
bigram・trigramの出現頻度を数え上げます。
このツール自体のファイルが最後に書き換えられた時刻を見ると、興味深い事実が分かります。
Wikipediaダンプの読み込み部分は3月28日06時28分、頻度の数え上げ部分は06時46分に
更新されており、形態素解析と多読字補正の中心部分は同じ日の15時34分に書き上げられて
います。awase本体にNgramModelが組み込まれたコミット(21時17分)より何時間も前、
その日の早朝から午後にかけて、Wikipediaを処理するための専用ツールが先に作られていた
ことになります。プロトタイプが動いた同じ一日のうちに、コーパスを作る側の作業まで
進んでいたのです。
生成されたデータは、決して小さくありませんでした。TOML形式で5.7MB、25万エントリ。
これをアプリに同梱するには重すぎます。3月30日、CSV+gzip形式への変換で1.3MB(23時41分の
コミット)まで圧縮し、さらに8分後、スコアが一定より低い低頻度のtrigramを間引くことで
240KBまで削りました。25万エントリのうち、実際に残されたのは4万3千エントリほどです。
低頻度のtrigramを削っても、判定への影響はほとんどありません。該当するtrigramが
見つからなければ、より粗いbigramへ、それも無ければ「判定しない」という中立の扱いへ
自動的に切り替わる設計だったからです。
際どさの外側では、統計は口を出さない
n-gramスコアの使われ方をもう一度確認します。frequency_score()はまずtrigramを参照し、
無ければbigramにフォールバックし、それでも無ければ0.0という中立値を返します。このスコアを
tanhで-1から1の範囲に正規化し、既定では前後20ミリ秒の調整幅に掛けて、30ミリ秒から
120ミリ秒という範囲に収まるよう閾値を動かします。
大事なのは、この仕組みが「かな漢字変換の候補を選ぶ」ためのものではないという点です。
awase自身は、かな漢字変換を行いません。それはOS側のIMEの役目です。awaseが決めているのは、
あくまで「どの一文字の仮名を確定させるか」という、もっと手前の一点だけでした。n-gramは
その一点を、タイミングという物理的な観測だけでは決め切れないときに限って、もう一つの
手がかりで補う役目を担っていました。
2026年5月8日、1f93e7dというコミットで、このn-gramモデルを設定ファイル上で明示的に
有効化する変更が入りました。コミットメッセージには、この章の冒頭で挙げた「よかった」が
「よがんた」になる現象が、3キー仲裁でn-gramモデルが効いていないことに起因すると
記録されています。タイミングだけを頼りに判定を続ける限り、この種の際どい打鍵は
一定の確率で必ず取り違えられます。文脈という、もう一つの独立した手がかりを足すことでしか、
この確率は下げられませんでした。
判定の型を、汎用の部品として持ち上げる
三つのキーの並びを追いかけ、シフトするかリデュースするかを決める処理そのものも、
初期のNicolaFsmでは再帰呼び出しの中に埋め込まれていました。3月30日のADR-015は、この
実装が抱えていた問題を三点挙げています。
update_history() が finalize_plan() の外でも呼ばれる(履歴更新の経路が分散)
combine_prev_and_new() で2つの Response を手動マージ(脆い)
self.phys の暗黙依存(再帰の奥で何を参照しているか不明)
ADR-015が採った解決は、パーサーの内部で起こりうる遷移のパターンをあらかじめ限定し、
ParseActionという型(列挙型、英語でenum)にすべて書き出すことでした。
enum ParseAction {
Shift { timer: TimerIntent },
Reduce { actions, record, timer },
ReduceAndContinue { actions, record, remaining },
PassThrough { timer: TimerIntent },
}
そのうえで、この列挙型を受け取って実際に駆動する部分は、timed-fsmという別クレートに
ShiftReduceParserという名前の汎用の型として切り出されました。NicolaFsmは、この型が
求める最小限の手続きを実装するだけの存在になり、手動マージだったcombine_prev_and_newは
削除され、経路が分散していたupdate_historyの呼び出しはループ内の一箇所に集約されました。
NicolaFsm側に残ったのは、この駆動部分を呼ぶだけの3行です。
fn on_key_down(&mut self, event) -> Resp {
self.update_timing(event);
let ev = self.phys.classified;
if let Some(reason) = self.bypass_reason(&ev) {
return self.handle_bypass(reason);
}
timed_fsm::parse(self, ev)
}
一つずつ受け取ったキーを、すぐには確定させずに貯めておく操作を「シフト」、パターンが
揃ったところでまとめて処理する操作を「リデュース」と呼びます。同時打鍵の判定は、この
シフトかリデュースかを逐次選びながら繰り返す、ストリーミングパーサーの一種にすぎません。
コンパイラの構文解析などで使われるこの計算モデルは「shift-reduceパーサー」と呼ばれて
います。この気づきによって、NICOLA固有の再帰処理は、その一般的な一実装へと置き換わり
ました。
時刻を、テストから切り離す
このtimed-fsmは、タイムアウトで遷移する状態機械を扱うため、内部で現在時刻を参照する
必要があります。ところが最初の実装はInstant::now()を直接呼んでおり、これは二つの問題を
抱えていました。実際に100ミリ秒待たなければ、タイムアウトの発火をテストで確認できない
こと、そしてInstant::now()がWindows固有の時計実装に暗黙に依存し、他のプラットフォームへ
移植する妨げになっていたことです。
5月30日、ADR-042はClockというトレイトを導入し、この依存を切り離しました。トレイトとは、
性質の異なる複数の型に対して「これができる」という共通の振る舞いを約束させる仕組みです。
ここでは「現在時刻を返せること」だけをClockという名前で約束させ、本番用とテスト用とで
中身だけを別々に用意しました。
pub trait Clock: Send + Sync {
fn now_ms(&self) -> u64;
}
pub struct MonotonicClock; // 本番用(std::time、Windows非依存)
pub struct ManualClock { current_ms: AtomicU64 } // テスト用、advance(ms)で時刻を進められる
本番では実時間をそのまま返すMonotonicClockを使い、テストではadvance(100)と呼ぶだけで
100ミリ秒の経過を即座に再現できるManualClockを使います。時刻という、観測するたびに
値が変わり続ける対象を、テストの中では自分の意のままに進められる値へ置き換えたことで、
タイムアウトの検証は待ち時間なしで書けるようになりました。
他分野への転用
際どい判定にだけ、独立した手がかりを足す
一つの観測だけでは判定が際どくなる場面は、日本語入力に限りません。予測変換やオート
コンプリートも、直前の入力だけでは次の候補を絞り切れないとき、単語の出現頻度という
統計的な文脈を判定材料に加えます。センサーフュージョンの分野でも、単一のセンサー値が
拮抗して読み取れないとき、過去の動きのパターンや周囲の情報といった、観測そのものとは
別の手がかりを組み合わせる設計が広く使われています。awaseがタイミングにn-gramを足した
構図は、この一般的なパターンの一例にすぎません。
ただし、この組み合わせは万能ではありません。観測同士の差が十分大きいときには、単一の
観測をそのまま信じるほうが速く、かつ正確です。統計的な補正は、観測同士が拮抗している、
まさにその瞬間にだけ呼び出されるべきものであり、常時介入させると、かえって本来明確
だったはずの判断を曖昧にしてしまいます。
タイミングが意味を持つ入力は、層に分けにくい
NICOLA方式のように、キーを押す間隔そのものが確定する文字を左右する入力方式は、この本が
初めて出会った設計ではありません。身近な例で言えば、スマートフォンの画面が「タップ」と
「長押し」を区別するのも、同じ仕組みです。指を離すタイミングだけで、まったく違う動作が
起こります。複数のキーを組み合わせて一つの文字や単語を確定させるチョード(和音)式
キーボードは、1980年代の特許群にすでに記録があり、速記用のステノタイプや、自作
キーボード向けのオープンソースファームウェアQMKが備えるtap-hold機能(タップと長押しを
キー1つで区別する機能)も、同じ種類の課題を扱っています。「押す」という単純な動作の中に、
タイミングというもう一つの次元の情報を埋め込む入力方式そのものは、数十年前から存在して
いました。
この種の入力に共通するのは、「まず状況を把握してから、それに基づいて決める」という素直な
二段階では割り切れない、という性質です。熱いストーブに触れたとき、手は「熱い」と頭で
考えるより先に引っ込みます。知覚してから考えて動く、という順序を待たない反応です。
ロボティクスの分野では、知覚・計画・行動という決まった順序でパイプラインを組む設計に対し、
Rodney Brooksが提唱したSubsumption Architectureが、その順序そのものへ異議を唱えたことで
知られています。信号処理の分野にも、まず「何かが起きたか」を判定してから「どれくらいの
大きさで起きたか」を推定するという段階を踏むより、両方を同時に扱うほうが精度が高いことを
示すJoint Detection and Estimation理論があります。NICOLA方式が「タイミングが意味そのものを
担っている場合、判定を知覚と決定にきれいに層分離できない」と教えてくれるのは、これらの
一般論を、親指シフトという具体的な入力方式でなぞり直した結果にすぎません。
設計原則
原則: 一つの観測だけでは判定が際どくなる場面には、独立した第二の手がかりを用意しておく。
適用条件: 観測同士の差が十分に大きい場合は、単一の観測だけで判定してよい。第二の手がかりが
必要になるのは、観測が拮抗し、単独では決め切れない場合に限られる。
実装の形: 主たる観測(このawaseの例ではタイミング差)による判定を基本とし、差が閾値を下回る
ときにだけ、統計的な文脈や事前知識といった別種の手がかりを参照する。
限界: 第二の手がかり自体も、確率的な傾向にすぎない。まれな連鎖や、統計に現れない新しい
文脈に対しては、判定を誤る余地が残る。
第4章:最初の一文字が化ける

第I部では、キーを受け取り、NICOLAの規則で判定し、仮名を出力するところまでの経路を組み立てました。三層構成、newtype、RAII(値の生存期間の終わりに合わせて資源の解放を自動的に行う仕組み)、Effectという仕組みは、すべてこの経路を正しく保つための工夫でした。
ここから先で問題になるのは、この経路の内側のロジックではありません。経路の外側にある存在、つまりWindowsやIME、フォーカスを奪い合う他のプロセスが返してくる情報を、どこまで信じてよいかという問題です。自分の側の判定がどれほど正しくても、判定の材料そのものが揺らいでいれば、結果は揺らぎます。
自分の書いたコードに誤りがあるなら、読み直せば見つかります。デバッガで追えば、どの行がどの値を作ったかを特定できます。しかし相手が返してくる状態そのものが、常に正しいとは限らないとしたら、読み直す先がありません。相手のソースコードは読めず、相手の内部状態を覗く公式な手段もないからです。第II部では、この前提から章を積み重ねていきます。
最初に出会った症状は、単純でした。ただし単純に見える症状ほど、原因が一つだとは限りません。同じ化け方が二度現れたとき、それを同じ原因だと決めつけてよいのか。この章は、その問いから始まります。
この章の症状は、WindowsやIMEが嘘の値を返したために起きたわけではありません。相手が、こちらの期待する速さで初期化を終えてくれなかっただけです。ただし結果から見れば、期待通りに動かない相手を、期待通りに動くものとして信じ切っていたという点で、根は同じです。
最初の一文字だけが、日本語にならない
「こ」と打ったつもりが、画面には「kお」という文字列が出ました。ローマ字入力の最初のキーだけが、IMEに渡る前に生の文字として出力されていました。二文字目以降は普通に変換されるため、ぱっと見には「たまたま一文字だけ壊れた」ようにしか見えません。しかし何度再現しても、壊れるのはいつも先頭の一文字でした。
この現象は一度だけではありませんでした。フォーカスを移した直後、変換を確定した直後、あるいは長時間放置した後に、繰り返し姿を見せました。この三つの瞬間には共通点があります。いずれもIME側が内部状態を新しく作り直す必要がある瞬間だという点です。
| 日付 | コミット | 打ったつもり | 実際に出た文字 |
|---|
| 2026-04-27 | c2a6052 | こ | kお |
| 2026-05-04 | 4409409 | これで | koれで |
| 2026-05-18 | 83d5707 | このぎょ | kおのぎょ |
| 2026-06-18 | 84e6942 | こちら | koちら |
四つの事例は半年近くにわたって現れています。しかも見た目はほぼ同じです。最初の一文字だけがローマ字のまま残り、残りは正しく変換されるという形が、毎回繰り返されていました。
表に挙げた以外にも、同じ形の化け方は繰り返し記録されています。「む」が「mう」になる例、三つのキーの分岐処理が重なって「この」が「こnおあたり」になる例もありました。原因を一つに絞り込む前に、まず現れ方の共通性だけが先に積み上がっていきました。
この種の症状は、狙って再現するのが難しいという性質も持っていました。フォーカスを移した瞬間、確定操作の直後、あるいは十分な時間放置した後といった、特定のタイミングでしか起こらないためです。何度打っても化けない試行が続いた後、忘れた頃に同じ化け方が現れる。そのたびに、前回の修正が本当に効いていたのかを、あらためて疑う必要がありました。
見た目が同じであることは、原因が同じであることを意味しません。しかし当時の私は、この二つを区別せずに調査を始めました。「前回直したはずの箇所を、また壊したのだろう」というのが、最初に浮かんだ説明でした。
この思い込みには理由がありました。バグ報告は、コードの内部構造ではなく、画面に映った結果として届きます。同じ結果が届けば、同じ原因を疑うのは自然な反応です。しかし原因の側から見ると、一つの結果に至る経路は一つとは限りません。この章で追う二つの事件は、まさにその一例です。
調査の手がかりは、当時のコミット履歴とADRに残された記録でした。どちらも後から読み返して初めて、二つの事件が別の系統に属することが分かります。渦中にいる間は、どちらも「最初の一文字が化ける」という同じ一行の症状としてしか見えていませんでした。
送信順序を逆にしたら、直った
最初に疑ったのは、キーイベントを送る順序でした。Windows Terminalは独自のTSF(Text Services Framework、Windowsの入力方式を仲介する仕組み)テキストストアを持つアプリケーションで、キーの上げ下げをどう束ねて送るかによって、IME側の合成バッファが化けることがありました。K↑のタイミングで合成バッファがコミットされてしまうなら、キーを束ねる順序を変えれば防げるはずだ、というのが当初の仮説でした。
一般的なWin32アプリでは、送るキーの順序を気にする必要はほとんどありませんでした。IMEとの仲介をOSが単純な形で行ってくれるためです。Windows Terminalのように独自のテキストストアを実装しているアプリだけが、送信順序に敏感に反応しました。この違いが、アプリの種類ごとに出力方式を切り替えるという設計の出発点でした。
2026年4月11日のd35dc09で、アプリの種類ごとに出力方式を切り替える設計を導入しました。当初Windows Terminalは一般的なWin32アプリと同じ扱いでしたが、翌日には独自TSFを持つアプリとして再分類し、数日後にはSendInputをまとめて送るBatched方式も試しています。それでも化ける現象は消えず、4月21日には設定ファイルで手動上書きできるforce_vkまで追加しました。自動分類のロジックを何度組み替えても直らないという事実は、原因が分類の精度ではなく、別の場所にあることを示す手がかりでした。ただし当時は、この手がかりにまだ気づいていませんでした。
4月27日、キーの送信順序を「押した順」から「重ねて押した順」に変更したところ、症状は消えたように見えました。ところがこの判断は一日と持ちませんでした。同じ日のうちに、別のバグ修正のついでに順序を元へ戻し、IME種別の自動検出を無効化し、また重ねる順序へ変え、逐次順に戻し、最後にもう一度重ねる順序へ確定する――という反転が、たった一日のうちに5回起きています。一つの変更が別の症状を連れてくるたびに条件が上書きされ、「直った」という判断の賞味期限が数時間しか持たない状態でした。
この日のうちに何度も設定が反転すること自体が、実は重要な兆候でした。修正のたびに手応えは感じるのに、次の症状が出るまでの間隔だけがどんどん短くなっていくなら、直しているのは症状の表面であって、原因そのものではないと疑うべきタイミングです。当たりを外したまま調整を続けると、変更のたびに何かが良くなったように見えても、全体としては同じ場所を行ったり来たりするだけになります。
4月29日、最終的な送信順序の確定をもって、「最初の一文字が化ける」問題は解決したと判断しました。何日も再現を試し、症状が出ないことを確認した上での判断でした。
同じ化け方が、別の場所で起きた
解決したはずの症状が、5日後の5月4日に再び現れました。「これで」が「koれで」になる、まったく同じ見た目の化け方です。何日も再現しないことを確認した後だっただけに、この再発は解決の判断そのものへの疑いにつながりました。
私は最初、送信順序の確定が不十分だったのだろうと考えました。4月29日に確定した重ねて押す順序を、あらためて見直しました。順序を変えて試しましたが、症状は変わりませんでした。この時点で、送信順序という説明は反証されました。
原因はまったく別の場所、Windows Terminalの内部初期化にかかる時間にありました。Windows TerminalはIMEをONにした直後、内部のコンポジション機構(変換中の文字列を組み立てて保持しておく仕組み)を再初期化します。この初期化が終わる前にローマ字キーが届くと、IMEを素通りして生の文字として出力されます。これが4月29日までの送信順序の問題とは別種の、TSFのコールドスタート問題でした。
送信順序の問題は「どちらを先に送るか」という、二択に近い形をしていました。しかしコールドスタートの問題は、「どれだけ待てば十分か」という、際限のない調整を要求する形をしていました。同じ症状の裏に、まったく異なる形の問題が隠れていたことになります。
この違いに気づいてからは、調査の進め方も変わりました。症状が同じだからといって前回の修正を疑うのではなく、まず前回の修正が今回も効いているかを確かめる。効いていなければ、原因は別の場所にあると考える。当たり前のようですが、渦中では見失いやすい順序でした。
対策として採用されたのが、IMEをONにした直後に一定時間だけ待ってからローマ字を送る、待機時間の追加でした。ADR-0002はこの待機を「TSFコールドスタート・ウォームアップ」と呼び、その調整の記録を残しています。5月4日の1703fcfで、ウォームアップ用にVK_DBE_HIRAGANAを先行送信する仕組みを導入しました。この値は16進数で0xF2にあたります。当時のコミットメッセージはこれを略して「F2」と呼んでいますが、キーボード最上段にあるファンクションキーのF2とは無関係の、IME専用の仮想キーコードです。以下、この本でも当時の呼び方に倣い「F2」と表記します。翌5月5日のbabce4cでは、この「F2」とローマ字キーを別々のSendInputに分けました。同じバッチで送ると、IME初期化が終わる前にローマ字側が処理されてしまうと分かったためです。
このあとも試行は続きました。特殊なスキャンコード(キーボードが物理的なキーごとに割り当てる識別番号)を付加する案を試し、効果がないと判断して除去する変更もありました。ウォームアップに使う値自体を、「F2」から別の値(VK_IME_ON)に差し替える変更も一度試み、翌日には元の「F2」に戻しています。一つひとつの変更は小さくても、合わせると迷走と呼べる量になっていました。
この試行錯誤は、5月4日から6月19日過ぎまで、一ヶ月半にわたって断続的に続きました。送信順序という一つの変数を確定させるのに要した日数と比べると、桁違いの長さです。相手が「いつ終わるか」という時間の見積もりは、相手が「どちらを先に受け取るか」という順序の見積もりより、はるかに御しがたい対象でした。
600ミリ秒から1500へ、そして500へ
待機時間の値は、症状が出るたびに調整されました。次の三つのコミットは、その調整の激しさを示しています。
| 順番 | 変更 | コミットメッセージの要旨 |
|---|
| 5月18日以前 | 500ms→600ms | aea5a25 セッション期限切れ時の強制待機、「kおれ」バグ対策 |
| 5月18日 01:00 | 600ms→1500ms | 83d5707 「このぎょ→kおのぎょ」バグ対策 |
| 5月18日 02:29 | 1500ms→500ms | 4249846 「adaptive warmup実装前の基準値」に戻す |
83d5707のコミットメッセージは、変更の根拠を数字で示していました。「Alt+Tab後のメッセージキュー洪水が約200ミリ秒続き、GJI(Google Japanese Input、Google 日本語入力。競合する別会社の日本語入力エンジン)の初期化に560ミリ秒かかる。合計760ミリ秒必要なのに対し600ミリ秒では足りない」という実測値が添えられ、余裕を持って1500ミリ秒に設定する、と書かれていました。ところがその1時間29分後、4249846は「adaptive warmup実装前の基準値」という理由で、1500ミリ秒を500ミリ秒に戻しました。実測に基づいて延ばした値が、実装前の暫定値だからという理由で三分の一へ戻されたことになります。一週間で少なくとも五回の変更があり、実測根拠を示した変更でさえ二時間も経たずに覆っています。この往復は、待機時間という考え方、ひいては固定値そのものへの信頼が、この時点で既に薄れていたことを示していました。
待ち方の仕組み自体も、この間ずっと変わり続けました。固定sleepからWaitForInputIdle(相手のプロセスが入力の処理を終えて待受状態に入るまで待つ、Windowsが提供する仕組み)へ切り替え、また固定sleepへ戻すという往復もありました。その後dbda95fでは、コールドスタートの要因をColdReasonという型で分類し、要因ごとに異なる待機値(NativeF2Consumedなら1000ミリ秒、それ以外なら500ミリ秒)を割り当てる変更が入りました。単一の固定値を探す発想から、状況に応じて値を変える発想への、最初の一歩です。
待つのをやめて相手の内部状態を直接覗こうとする試みも二つありました。5月18日、IME側が使うプロセス間通信ファイル(session.ipc)へのアクセス時刻を監視する仕組みと、Windowsのメッセージキューへ空のメッセージを送り込み応答の有無を見る仕組みです。後者は同じ日のうちに「効果がなく、むしろ悪化した」として取り消されています。相手の内部実装が見えない以上、外側から観測できる結果を頼りに、値や覗き方を変えて反応を見るという試行が、この一ヶ月半のほとんどを占めていました。
44ミリ秒の差が、化けるかどうかを決めていた
待機の仕組みは、固定sleepから一歩進み、WindowsのUIオートメーションが発するイベント(WinEventのOBJ_NAMECHANGE)を監視する方式に置き換わっていました。相手側の状態変化を、時間ではなくイベントで捉えようとする試みです。ただしこの監視にも、待てる上限としてNameChangeWait 300ミリ秒という値が残っていました。上限そのものは、依然として時間で決められていたのです。
6月18日、55797ミリ秒(約15分)アイドルした後に「こちら」が「koちら」になる症状(84e6942)を調べたとき、初めて内訳が数字として明らかになりました。長時間アイドル後という条件は、5月4日以来何度も見てきたコールドスタートの、もっとも厳しい形です。この調査で、ようやく「なぜ44ミリ秒だけ足りないのか」という粒度の答えが得られました。
- 実物の「F2」(#0)とウォームアップ用の「F2」(#1)を送信し、GJIの初期化完了を312ミリ秒後に検知する。
- その完了を受けて、確認用の「F2」(#3)を送信する(この時点でt=312ミリ秒)。
- Windows Terminal側は、「F2」を受け取ってからコンポジション再初期化に実測344ミリ秒を要する。
- 待機の締切(NameChangeWait 300ミリ秒)に従い、t=612ミリ秒でローマ字キー(K・O)を送信する。
- Windows Terminalの準備が整うのはt=656ミリ秒であり、送信はその44ミリ秒前だった。
- K・OはGJIを素通りし、ターミナルに直接「ko」というASCII文字列として出力される。
時系列に並べると、締切と準備完了がすれ違う様子が見えます。

44ミリ秒の差で、キーはIMEを素通りしてターミナルへ直接出力されていました。待機時間をいくら調整しても、この差はいつか必ず起こり得ます。相手の初期化が終わる時刻を、時間の見積もりだけで言い当て続けることはできないからです。しかもこの44ミリ秒は、Alt+Tabの有無、システムの負荷、アイドル時間の長さによって変わります。どれだけ精密に測っても、次に同じ数字が出る保証はありませんでした。
600ミリ秒から1500ミリ秒への変更も、NameChangeWaitの300ミリ秒という上限も、根は同じでした。どちらも「相手はこのくらいの時間で終わるはずだ」という見積もりに過ぎず、見積もりである以上、外れる場合が必ず存在します。5月18日の1時間29分での往復は、この見積もりの不安定さを、期間を縮めて見せていただけでした。
ここで問題の見方が変わりました。「正しい待機時間を探す」問題ではなく、「相手が終わったかどうかを知る」問題だったのです。それまでの設計は、送る前に「もう終わっているはずだ」と賭けていました。採用された設計は、その賭けをやめ、送信した後に実際どうなったかを確認する方式でした。賭けて外れれば取り返しがつきませんが、確認してから直すなら、外れても取り返せます。
// 旧: 待機時間を信じて送る
sleep(eager_settle_ms);
send_romaji_batch(&keys);
// 新: 送ってから、化けたかどうかを確認する
send_romaji_as_tsf_warm(&keys);
if literal_detected(&keys) {
backspace(keys.len());
send_romaji_as_tsf_warm(&keys); // warmのまま再送(coldにはしない)
}
LiteralDetectFsmと名付けられたこの仕組みは、送信結果を監視し、リテラル化を検出した場合にのみバックスペースで取り消し、warm状態を保ったまま再送します。待機時間の調整はここで終わりました。
再送を「cold」ではなく「warm」のまま行う点には理由があります。coldとして再送すると、「F2」による再ウォームアップから始まってしまい、Windows Terminal側の344ミリ秒の再初期化タイマーがまた最初から動き出します。warmのまま直接VKキーを送れば、既に準備が終わっているWindows Terminalへ、待たずに正しく届きます。
600ミリ秒から1500ミリ秒への調整も、44ミリ秒の差の分析も、結局は「相手がいつ終わるか」を外側から言い当てようとする試みでした。LiteralDetectFsmは、言い当てることをやめ、送った結果を見てから判断するという、逆向きの発想に立っています。
この設計にも限界は残っています。文字列全体がリテラル化した場合は検出できますが、一部の文字だけがリテラル化し、かつIMEの候補表示自体は正常に発火した場合は、成功したと誤って判定されたままでした。候補表示という一つの合図だけでは、内部の文字列がどこまで正しく渡ったかを区別できなかったのです。検出の仕組みを持ち込んだことで、賭けに頼らずに済むようになりましたが、検出そのものの精度には、まだ穴が残っていたことになります。
利用者から見れば、全体がローマ字のまま残るよりも、一部だけが混ざる方がかえって気づきにくい場合があります。変換された部分に紛れて、違和感が薄まってしまうためです。検出という発想そのものは正しくても、何を検出の根拠にするかによって、救える範囲は変わります。この部分リテラルの検出は、今も未解決の課題として残っています。
他分野への転用
朝、取引先に電話をかける前に「もう始業しているはずだ」と決め打ちして掛けると、まだ準備中で出てもらえないことがあります。逆に、確実を期して昼まで待てば、今度は相手を待たせすぎたことになります。「相手の準備が整うまでにかかる時間」を外から言い当てようとする限り、早すぎるか遅すぎるかのどちらかに振れ続けます。
分散システムの世界には、この問題に正面から取り組んだ仕組みがあります。Kubernetes(コンテナ化されたアプリケーションを多数のサーバーにまたがって運用するための基盤ソフトウェア)は、新しく起動したコンテナに通信を回してよいかどうかを、起動から何秒経ったかという固定時間では判断しません。readinessプローブと呼ばれる確認要求を実際に送り、応答が返ってくるまで通信を回さないという設計を採っています。同じ仕組みには、動作中のコンテナが実は固まっていないかを継続的に確かめるlivenessプローブという、別の役割の確認要求もあります。前者は「まだ始まっていないものを、早く扱わない」ため、後者は「止まってしまったものを、いつまでも動いていると誤解しない」ためのものです。
似た発想は、ネットワーク通信の再送タイマーにも古くからあります。TCP(インターネットの基本的な通信規約の一つ)は、送ったデータへの応答がどれくらいで返ってくるかを通信のたびに実測し、その実測値をもとに次の再送までの待ち時間を調整します(Karnのアルゴリズムとして知られる手法です)。固定の待ち時間を最初から決め打ちするのではなく、相手の応答という実測できる合図に基づいて待ち時間そのものを更新し続けるという点で、LiteralDetectFsmが「送ってから確認する」方式へ転換したのと、根は同じ発想です。
固定待機時間という近似が、どの分野でも同じ理由で行き詰まるわけではありません。Kubernetesのreadinessプローブは、確認のための通信を追加で送るという代償を払っています。TCPの再送タイマーは、実測にもとづく調整であっても、その調整が追いつくまでには必ず何度かの試行錯誤を要します。「完了を検出できる合図に置き換える」という原則は共通していても、その合図をどう安く手に入れるかは、対象ごとに異なる問題として残ります。
設計原則:固定待機時間は、状態遷移の代用品にはならない
対象の処理が完了したかどうかを、経過時間の長さで言い当てようとすると、いずれ実際の所要時間との差が顕在化します。600ミリ秒から1500ミリ秒、そして500ミリ秒へという一週間の調整も、44ミリ秒の差で化けるかどうかが決まったことも、この差が現実に起こり得ることを示しています。値をどれだけ調整しても、待機時間という近似の形そのものは変わりません。値を変える作業は、原則そのものの見直しを先送りにしているだけだったとも言えます。
この原則が働くのは、相手側の処理時間が環境やタイミングによって変動する場合です。処理時間が完全に一定であることが保証されているなら、待機時間による近似でも実用上問題は起きません。しかし相手が自分の管理下にない外部プロセスであり、しかもその内部実装を知る手段がない場合、処理時間が一定である保証はまず得られません。相手のバージョンが変わるだけで、必要な時間も変わり得ます。相手のCPU負荷や、直前に何が起きていたかによっても変わります。適用条件を見誤ると、待機時間はいつまでも「もう少し延ばせば直る」ものに見え続けます。
実装の形としては、待機時間を調整するのではなく、完了そのものを検出できる合図に置き換えます。今回であれば、送信結果を観測し、意図した通りに反映されたかを確認する方式が、待機時間の調整に代わるものでした。事前に賭けるのではなく、事後に確認して、必要なら取り消して直すという順序に変えたことが要点です。
ただし、完了の合図が来ない場合に無限に待ち続けるわけにはいきません。タイムアウトは、状態を表現する手段としてではなく、異常を打ち切るための安全弁として残します。時間を状態の代用品にしないことと、時間を安全装置として使うことは、両立します。両者を混同しないことが、この原則を正しく適用する上での分かれ目になります。
保証しない範囲についても述べておきます。この原則は「完了したかどうか」を確認する手段があることを前提にしています。確認の手段そのものが得られない相手に対しては、この原則だけでは足りません。今回は送信結果を事後に確認できたために解決しましたが、確認する方法自体をどう見つけるかは、別の問題として残ります。
第5章:「わからない」を値にする

DecisionExecutorという構造体には、次のようなフィールドがありました。
applied_snapshot: Option<(bool, u64)>
RustのOption型は、値が「存在する」場合と「存在しない」場合を型として区別して
表現するための仕組みです。ここでのboolはIMEのONかOFFかを表す真偽値、u64は0以上の
整数です。一見すると、ただの
bool値とタイムスタンプの組です。しかし当時のADR-044は、この一行が実際には三つの状態を
同時に表していたと記録しています。
None // フォーカス直後・起動時 — 実 IME 状態が完全に不明
Some((v, 0)) // 楽観更新 — async 完了前の事前書き込み。未確認。
Some((v, ts)) ts>0 // 確認済み — 実 apply 完了後。信頼できる状態。
読み取るべきことは一つです。tsという同じ整数のフィールドが、「まだ確認していない」
「仮に書いただけ」「確認済みである」という、性質の異なる三つの意味を一人で背負っていました。
ts == 0は、コードのどこにも明文化されていない、コメントだけが知っているセンチネル値でした。
コードを変更するたびに、開発者はコメントとコードの対応を目で追い直す必要がありました。
「ts = 0」が三つの意味を一人で背負っていた
この構造がなぜ生まれたのかを理解するには、IMEの状態がどう更新されるかを見る必要があります。
awaseは、ユーザーの操作に応じてOS側のIMEをON/OFFする際、まず自分の側の値を先に書き換えます。
これは楽観的な事前更新で、実際にOSへ反映されたかどうかは、非同期処理が完了するまで分かりません。
確認を待ってから次のキー入力を処理すると、入力への反応が遅れて見えてしまうため、確認前の値を
先に仮置きする設計そのものは妥当でした。そのためapplied_snapshotは、「まだ何も書いていない」
「書いたが確認していない」「書いて確認も取れた」という三段階を、一つの型の中に同居させて
いました。
タプルの第二要素であるat_msは、本来は「確認できた時刻」を記録するためのフィールドでした。
しかし確認前の値を仮に置く必要があったため、0という値が「まだ確認していない」の代役を
兼ねることになりました。整数としての0と、意味としての「未確認」は、たまたま同じ場所に
同居していただけで、型としては区別されていませんでした。
この同居が厄介なのは、0という値そのものが特別に見えない点です。at_ms > 0という条件式は、
読み手に対して「時刻が記録されている」としか語りません。「確認済みかどうか」という判断を
そこから読み取るには、書いた本人がコメントを残すか、後から読む人がADRを掘り起こすしか
ありませんでした。
さらにADR-044は、実際の判断が三段階だけでは済んでいなかったことも記録しています。Effectの
発生源/IMM32によるクロスプロセス検出の可否/GJI(Google Japanese Input、Google 日本語入力)の健全性/適用したい方向/確信度/経過時間
という六つの軸の組み合わせが、単体テストの難しい40行ほどの条件分岐に埋め込まれていました。
六つの軸をすべてif文の連鎖だけで場合分けしようとすると、組み合わせ爆発を避けられません。
ts一つに三つの意味を持たせた構造は、この六軸の判断がどこか一箇所に集約されないまま、
呼び出し側ごとに書き直されることの温床にもなっていました。
同じ整数やboolに複数の意味を重ねて持たせる書き方は、awaseに限った習慣ではありません。
初期値としての0と、「まだ計算していない」ことを示す0が同じ数字である言語やAPIは
珍しくなく、境界条件の判定を誤らせる典型的な原因の一つです。applied_snapshotの場合、
その境界条件の誤りが、IMEの状態という利用者の目に見える挙動へ直接つながっていました。
「IMEはboolで十分だ」という前提が崩れた
applied_snapshotが複雑化する前、awaseの前提はもっと単純でした。IMEの状態はONかOFFかの
boolであり、awase側でキャッシュしておけば十分だという前提です。この前提はしばらく機能して
いましたが、Win11のメモ帳やChromeのようなModern UIアプリで崩れました。IMM32は、Windowsが
古くから用意している、IMEとやり取りするためのAPI群です。IMEのON/OFF状態や変換モードを、
アプリケーション側から尋ねたり指示したりするために使います。旧来のWin32アプリでは
IMM32によるクロスプロセス検出がおおむね素直に働き、awase側のキャッシュとOS側の実際の値が
食い違う場面はまれでした。前提が単純なままで済んでいたのは、対象とするアプリの側が単純
だったからにすぎません。
ADR-005によれば、これらのアプリではクロスプロセスIME検出が常にopen=0を返します。実際には
IMEがONであっても、OS側の検出APIは「OFF」としか答えません。この検出の失敗を吸収するために、
awase自身が推測を保持するshadow stateへのフォールバックが導入されました。ところがshadowを
更新する仕組みがまだ整っておらず、「IMEをOFFにしてもNICOLA変換が継続する」という一次バグが
発生しました。画面上の表示はOFFなのに、キー入力は同時打鍵の変換規則で処理され続けるという、
利用者から見れば表示と挙動が食い違う状態です。IMEの状態をON/OFFのboolでキャッシュすれば
足りるという当初の前提は、ここで実際に崩れました。キャッシュする値そのものが正しくても、
その値が「いつ、何によって確認されたか」を保持していなければ、古いキャッシュと新しい現実の
区別がつかなくなります。
この問題を掘り下げる過程で、ADR-029は既存の類似ツール(AutoHotkey/zenhan/alt-ime-ahk/
Keyhac等)も調査しています。IMM32はスレッドローカル設計であり、クロスプロセスの検出には
本質的に向きません。同じプロセス内のスレッドを想定した設計のため、別プロセスのIME状態を
覗こうとすると、信頼できない値を返しやすくなります。Chrome・UWP・Electronでは
WM_IME_CONTROLによるブリッジも機能しません。AutoHotkeyやKeyhacのような、広く使われている
ツールでも、この壁は越えられていませんでした。同じ制約に他のツールもぶつかっているという
事実は、awase固有の実装不足ではなく、Windowsのアーキテクチャそのものに起因する制約である
ことを裏付けています。ADR-029の結論は率直でした。クロスプロセスIME検出には、完全な解決策が
存在しないというものです。
解決策が存在しない以上、ADR-029は検出の失敗を前提にした多層の防御を組みました。
Layer 1: Shadow 追跡(即時、キーイベントベース)
Layer 2: OS 検出(500ms ポーリング + フォーカスフック)
Layer 3: フォールバック(検出失敗が続いた場合のみ昇格)
Layer 1はawase自身がキー入力の時点で仮の値を持ちます。OS側の検出結果を待っていては
入力への反応が遅れてしまうため、まず自分の推測を先に立てておく層です。Layer 2はOS側の
検出結果を定期的に問い合わせ直します。Layer 1の推測がいつまでも正しいとは限らないため、
実際の状態と繰り返し照合し直す層です。両者が一定回数(ime_detect_miss_count >= 3)以上
食い違ったときだけ、
Layer 3としてawase側の値をOSへ書き戻すime_force_on_guardが働きます。ただしこのガードは、
awaseが恒常的にOS側より信頼できる主(あるじ)になるという設計ではありません。挙動が未確認の
アプリを初めて検出しようとするブートストラップの期間と、内部状態を強制的に立て直す
panic_reset()の直後という、二つの限られた場面だけに働く一時的な猶予です。フォーカスが
変わるたびにガードはリセットされ、ウィンドウごとの状態を尊重し直します。「わからない」を
無期限に居座らせないための、期限付きの例外だったといえます。
三層の防御は、検出が失敗したときにawaseがどう振る舞うかという運用上の問題には答えました。
しかしこれは、値そのものをどう表現するかという問題とは別です。ONかOFFかというbool一つに
「まだ確認していない」「仮に書いただけ」「確認済み」という三つの状況を押し込める限り、
どれだけ層を重ねても、コードを読む人が状態を取り違える余地は残り続けます。層で運用を守る
だけでなく、値そのものの持たせ方を変える必要がありました。
TSF(Text Services Framework)は、IMM32を置き換える目的でWindowsに後から加わった、IMEと
やり取りするための新しいAPIです。同じ時期、awaseはIMM32・TSF・GJIのI/Oカウンタ・フォーカス
変化の観測という、複数のprobeを並行して走らせていました。それぞれのprobeが食い違う値を返すことも珍しくなく、「どの観測を
今の判断として使うべきか」という問いが、probeを呼び出す側のコードごとに個別に答えられて
いました。この判断をどこか一箇所へ集約する仕組みは、当時のコードにはまだありませんでした。
先に決めなければならなかったのは、集約する場所ではなく、そもそも一つひとつの値をどんな形で
持つべきかということでした。
「わからない」を、値として型に持たせる
ADR-044が最終的に採用した設計は、Option<bool>とセンチネル値の組を、専用の列挙型に
置き換えることでした。
/// IME apply 結果の確信度。
/// `Option<(bool, u64)>` の暗黙のセンチネル値(ts=0)を型で置き換える。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AppliedImeState {
Unknown,
Optimistic(bool),
Confirmed { open: bool, at_ms: u64 },
}
三つのバリアントを読み取るときに大事なのは、それぞれが「値」だけでなく「その値をどう知ったか」
という約束を表している点です。遷移を図にすると、フォーカス変更がいつでもUnknownへ
引き戻す、一方向的な流れになっていることが分かります。

Unknown――まだ何も確認していない
フォーカスが変わった直後や、awase起動直後がこれに当たります。実際のIME状態については、
まだ一度も観測が届いていません。以前のコードではNoneがこの状態を表していましたが、
「一度も観測していない」という積極的な事実は、Noneという消極的な不在からは読み取れません
でした。
Optimistic(bool)――OSにはまだ確認されていない
ユーザー操作に応じてawaseが値を書き換えた直後、非同期のapply処理が完了する前の状態です。
以前はSome((v, 0))という、ts=0という特殊値によってのみ表現されていました。Optimistic
という名前自体が、「この値は仮のものであり、まだ裏付けが取れていない」という約束を運びます。
この値を参照するコードは、名前を見た時点で「確定した事実として扱ってよいか」を判断できます。
ts == 0という条件式を思い出す必要はありません。
Confirmed { open, at_ms }――確認できた時刻ごと保持する
実際にOSへの反映が完了し、確認が取れた状態です。at_msは「確認できた時刻」であり、
以前のように「0でなければ確認済み」という間接的な判定を必要としません。この状態だけが、
後続の判断に安心して使ってよい値です。at_msという時刻そのものは、この章の範囲では
「いつ確認したか」を記録するだけの情報にとどまり、その時刻からどれだけ経てば古いと
見なすべきかという判定には、まだ使われていません。
三つのバリアントへの遷移も明文化されました。起動時とフォーカス変更直後はUnknownへ、
非同期処理の事前書き込み時はOptimisticへ、そして同期経路・非同期経路のいずれで
apply処理が完了した場合もConfirmedへ遷移します。以前は同じ条件分岐がコードの各所に
散らばっていましたが、遷移が三つのバリアントに紐づいたことで、どの経路がどの状態を
生み出すかが一箇所で見渡せるようになりました。
型を入れ替えた効果は、実際のコードの前後を比べるとわかります。
// Before: センチネル値の意味を追う必要がある
shadow_on == open && applied_at_ms > 0
// After: 型が意図を語る
matches!(state, AppliedImeState::Confirmed { open: s, .. } if *s == open)
「Before」の一行は、applied_at_ms > 0が「確認済みであること」を意味すると知っている
人にしか読めません。「After」の一行は、Confirmedという名前そのものが、それを読む条件を
語っています。三値論理を導入したこと自体よりも、値だけでなく、その値をどのように知ったかを
型に含めたことが、この設計の核心でした。
Rustのmatchはすべてのバリアントを網羅しないとコンパイルが通りません。将来もし四つ目の
状態を追加した場合、既存のmatch式のうち見直しが漏れている箇所を、コンパイラが機械的に
指摘してくれます。Option<(bool, u64)>とtsの比較だけでは、この種の検査は働きません。
Option<bool>のままでも、三つの状態を表現しようと思えば表現できたはずです。しかしそれでは
NoneとSomeの区別しか型検査が守ってくれず、Someの中身が「仮の値」か「確認済みの値」
かは、結局コメントか呼び出し側の注意力に頼ることになります。三つのバリアントを別の名前に
分けたことで、コンパイラが「まだOptimisticの段階なのにConfirmedとして扱っていないか」を
検査できるようになりました。人間の注意力に頼っていた区別が、型検査の対象に変わったことが、
実質的な変化です。
この置き換えは、テストの書きやすさにも表れています。三つのバリアントへの遷移は、
フォーカス変更やapply完了といった入力を渡せば出力の状態が一意に決まる、純粋な関数として
書けます。実際のIMM32やTSFを呼び出さなくても、UnknownからOptimisticへ、
OptimisticからConfirmedへという想定済みの遷移を、テストコードの中だけで再現して
確かめられます。Windows環境を用意し、実際にIMEを切り替えて目視で確認するという手間を
減らせたのは、値の意味を型で確定させたことの副産物でした。
Physical AIへの接続
センサーの値にも、確認済みと推定の違いがある
この設計は、IME固有の工夫には見えません。ロボットが扱うセンサー値にも、同じ構造があります。
障害物までの距離や自己位置の推定値は、「検出できたか、できなかったか」という二値に丸めた
瞬間に、「かつて確認された値」と「いま確認できていない値」の違いが失われます。値そのものを
持っているかどうかと、その値を今の判断に使ってよいかどうかは、別の軸です。センサーが一度も
値を返していない状態と、直近まで値を返していたが今は途切れている状態も、二値の成功/失敗
フラグでは区別できません。
Unknown・Optimistic・Confirmedという区別は、この二つの軸を型として分離する、
一つの実装パターンにすぎません。センサーの値がいつ観測されたものかを型に含めておけば、
「値はあるが古いかもしれない」状態を、「値がまったくない」状態と混同せずに済みます。二値の
成功/失敗フラグでは、この二つの状態は同じ「失敗」に潰れてしまいます。
判断を下す側のコードにとって、この違いは軽視できません。「値がない」なら安全側にフォール
バックする以外の選択肢はありませんが、「値はあるが未確認」なら、その値を仮の判断材料として
使いつつ、確認が取れ次第上書きするという振る舞いを選べます。二つの状態を一つに潰した設計は、
この選択肢そのものをコードから奪ってしまいます。
ただし、この型が保証する範囲には限りがあります。Confirmedは「確認できた」という事実だけを
運び、「その確認がいつまで有効か」までは答えません。確信度を型で表現できても、その確信度が
古くなる境界線は、まだこの章の設計の外にあります。
ts = 0という一つの整数から始まったこの章は、結局のところ「値の中身」と「値の由来」を
同じ場所に押し込めないという、一つの規律に行き着きました。この規律が答えなかった問い、
つまり由来が確かだった値がいつまで確かでいられるかという問いは、次の観測が積み重なるほど
重みを増していきます。
設計原則
原則:「わからない」は失敗として握りつぶすのではなく、明示的な値として持たせる。
適用条件: 観測の確信度が複数の段階(未観測・仮の値・確認済みなど)に分かれ、それらを区別
しないと後続の判断を誤りうる場合に適用する。
実装の形: Optionやbooleanにセンチネル値を隠すのではなく、確信度ごとに列挙型のバリアントを
分け、各バリアントがどの条件で生成されるかをコード上に明文化する。
限界: この型は「今何を知っているか」を表現するに留まる。「その知識がいつ古くなるか」を
保証するものではない。
第6章:過去から届く観測

ALT+TABをしただけで、日本語入力が消える
2026年7月4日の朝、奇妙な報告がメモに書き付けられました。ALT+TABでウィンドウを
切り替えただけで、日本語入力がオフになっている。キーを押し間違えたわけではありません。
何も入力していないのに、IMEが勝手に閉じているのです。
原因はすぐに見当がつきました。ALT+TABのタスクスイッチャーが表示されている間、OSは
選択用のUIウィンドウ(XamlExplorerHostIslandWindowのような一時的な窓)へ、ほんの一瞬
フォーカスを移します。ちょうどそのタイミングで起動していた観測処理(ImmCrossProbe)が、
この一時ウィンドウを対象にIME状態を読み取り、「オフ」という値を返しました。当時のこの
観測は「高信頼度」の扱いを受けていたため、現在の信念(desired_open)へそのまま採用され、
IMEエンジンをオフにするカスケードが走りました。
奇妙なのはここからです。この観測自体は、読み取った瞬間には嘘をついていませんでした。
一時ウィンドウのIME状態は、確かにオフだったのです。問題は、その観測が届いた
ときには、フォーカスはもう別のウィンドウへ移っていたということでした。過去のある時点
では正しかった観測が、届いた時点ではもう別の文脈の下にいるのに、時刻だけを頼りに現在の
状態として採用されてしまう。
時系列に並べると、観測の対象と、その結果が使われる時点のずれが見えます。

最初は、見たことのない新種のバグに見えました。ALT+TABという操作自体は何百回、何千回と
繰り返されてきた日常的な操作であり、そこに潜む競合が今になって表面化したことも不思議
でした。しかし調べていくうちに、既視感がありました。似た構造の問題には、すでに5月の
時点で一度向き合っていたはずだったのです。当時のコミットログを掘り返すと、確かに同じ
主題を扱ったリファクタリングが4回も連なっていました。しかも、そのうち3回はわずか1週間の
うちに集中していました。「解決した」はずだった問題は、実は解決していなかったのです。
この章は、なぜこの問題が2026年7月まで生き延びたのかを辿る章です。答えを先に
言うと、同じ問題に対する手当ては、5月から3か月近くにわたって何度も打たれていました。
にもかかわらず、7月4日にまた同じ顔で現れたのです。
信念を一か所に集めれば解決すると思っていた
時間を5月まで巻き戻します。当時向き合っていたのは、もう少し素朴な症状でした。
非同期に完了するIME操作(ImmCross経由のSendMessageTimeoutWなど)の完了通知が、
発行から数十〜数百ミリ秒遅れて届くことがあり、その間にユーザーが新しい意図を出していると、
古い応答が新しい意図を上書きしてしまうのです。当時の記録にはこうあります。
async ImmCross apply は SendMessageTimeoutW を含み数十〜数百 ms かかることがある。
その間にユーザーが新しい IME 操作を行うと、古い apply の async 完了 event が遅れて
到着し、新しい意図を上書きしてしまう。
SendMessageTimeoutWは、別プロセスのウィンドウへメッセージを送り、相手が応答するか
タイムアウトするまで待つWindows APIです。相手プロセスが忙しければ、応答は簡単に
数百ミリ秒遅れます。この遅れそのものを止めることはできません。止められるのは、
遅れて届いた応答をどう扱うかだけです。
このとき立てられた仮説は、単純でした。IME状態についての「信念」があちこちに分散して
いるから、上書き事故が起きるのだ。ならば、信念を一か所に集めてSSOT(Single Source of
Truth、単一の真実源)にすればよい。分散していた場所を数え上げると、確かに多すぎました。
フォーカス変更を検知するコード、非同期apply完了を受け取るコード、GJI(Google Japanese
Input、Google 日本語入力)のポーリング結果を受け取るコード——それぞれが自分の判断で
「今のIME状態」を書き換えていました。IMM32(Input Method Manager)は、Windowsが長く
提供してきたIME連携の旧来APIです。TSF(Text Services Framework)は、その後継として
Windowsが提供するIME連携の仕組みです。IMM32によるクロスプロセス検出、TSFの通知、GJI
のI/O観測という複数の経路が、それぞれ別のタイミングで「今のIME状態はこうだ」と申告
してきます。どの申告を信じるかという判断が、コードベースのあちこちに散らばって
しまうのは当然でした。この仮説のもとで、同じ構造物に
4回名前を変えながら実装をやり直すことになります。
改名遍歴——名前を変えるたびに、答えようとした問いが変わっていた
以下の4つの名前は、単なる呼び方の変遷ではありません。それぞれが「この構造物は何に
答えるべきか」という問いの立て直しでした。
| 名前 | 答えようとしていた問い | 隠れていた仮定 |
|---|
BeliefStore | 現在何を信じるか | 信念は一つに集めればよい |
ImeApplyLatch | 適用済みか | 適用履歴が次の判断を制御できる |
LastAppliedImeState | 最後に何を送ったか | 最後の命令が現在状態を代表する |
shadow_model | システムの現在状態は何か | SSOTなら競合はなくなる |
こうして並べてみると、4つの名前は退化しているようにも、進化しているようにも見えます。
最初のBeliefStoreは「信じる」という認識的な言葉を使い、次のImeApplyLatchは
「適用する」という行為の言葉に変わり、LastAppliedImeStateは「最後に何を送ったか」
という履歴の言葉に変わり、shadow_modelは「システムの現在状態」という存在論的な言葉に
戻ります。振り返ってみれば、毎回「この構造物の役割は何か」が問い直され、
少しずつ違う答えにたどり着いていたことになります。しかもこの問い直しは、思いつきで
起きたわけではありません。それぞれの改名の直前には、GJIがポーリング方式から
observer_poll経由の通知方式へ切り替わったことや、「適用ログ」と「真実源」を混同していた
ことへの気づきといった、具体的なコード側の変化がありました。以下の4つのコミットは、
その変化のたびに何が引き金になったかを示しています。
日付を並べると、最初の2つの改名がいかに急いで行われたかが分かります。

順番に見ていきます。
最初の一歩はd2e183f(5月22日18:43)でした。コミットメッセージにはこうあります。
refactor(Phase C): ImeBeliefStore で shadow_ime_on を Confirmed/Intended/Unknown に
型区別
- tsf/belief.rs: ImeOpenBelief enum と ImeBeliefStore struct を新規追加
- on_focus_changed() で belief.invalidate_on_focus_change() を呼び Unknown に降格
- notify_ime_open → record_intent (意図)、新規 record_observation (実測値) を追加
この時点で、すでに「意図」と「実測値」を型で分けるという発想に到達していたことが
わかります。ImeOpenBeliefという列挙型に、Confirmed(確認済み)・Intended(意図済みだが
未確認)・Unknown(不明)の3つの状態を持たせ、フォーカスが変わった瞬間には無条件で
Unknownへ降格させる。これは第5章で扱った「わからない」を型として持つという発想の、
IME状態版の応用でもありました。方向性としては悪くありません。ところが、この改良には
わずか42分しか寿命がありませんでした。同日19:25、f8dd8d4で次の変更が入ります。
refactor(Pass B): ImeBeliefStore → ImeApplyLatch、shadow_on の役割を明確化
Confirmed/Intended/Unknown の3値は GJI が observer_poll 経由になった後は役割が
重複していた。ラッチ(最後に apply_ime_open に渡した値)としての役割だけが残るため
Cell<Option<bool>> に単純化。
3値モデルを導入したその同じ日のうちに、それは「重複していた」として単純なラッチへ
後退させられています。ここで問いが変わりました。「現在何を信じるか」ではなく「最後に何を
適用したか」を答える構造物になったのです。
2日後の6baabf9(5月24日01:35)では、さらに問いが動きます。
refactor: ImeApplyLatch → LastAppliedImeState、shadow_ime_on → last_applied_ime_on
「最後に apply_ime_open で送った値のログ」と SSOT を区別するためリネーム。
Preconditions.ime_on が唯一の SSOT であることを doc comment で明示。
ここで、「適用ログ」と「真実源」は別物だと気づかれ、名前でその違いを明示する試みが
なされました。興味深いのは、このときのdocコメントが「唯一のSSOTは
Preconditions.ime_onである」と明記していたことです。つまりこの時点で、3回改名されてきた
当の構造物(shadow_ime_on→last_applied_ime_on)は、SSOTそのものではなく、SSOTの隣に
置かれた副産物にすぎませんでした。真実源はまったく別の場所に存在していたのです。この段階
では、「集める」という作業が、まだ本当に集め切れていませんでした。
この後退には、当時なりの理由がありました。GJIというコンポーネントがobserver_poll
経由で状態を渡すように変わったことで、3値のうち「Intended」が実質的に使われなくなって
いたのです。使われない区分を残すより、実際に使われている「最後に何を送ったか」だけを
正確に保つほうが健全に見えました。しかしこの判断は、後から振り返ると別の意味を持ちます。
3値モデルは「複雑すぎたから」捨てられたのではなく、「まだ何を表現すべきかを決め切れて
いなかったから」捨てられたのです。
最後の到達点はa4db93e(5月29日00:21)です。
refactor(state): Phase 3e — shadow_model を IME SSOT に昇格し belief.ime_on /
ImeObservations を撤去
ImeBelief.ime_on・ShadowSource・ImeObservations・apply_ime_observations() は
shadow_model.effective_open() に完全移行済みのため削除する。
ここでようやく、shadow_modelへの一本化が完了しました。分散していた信念の置き場所は、
たった一つの構造物に集約されたのです。Preconditions.ime_onという副次的なSSOTと、
last_applied_ime_onという適用ログという、2つに分かれていた真実は、この一回のリファ
クタリングでようやく1つに統合されました。実に7日間、4つの名前、2段階の権限移動を経て、
ようやく「信念の置き場所は1つである」という当初の目標そのものは達成されたことになります。
42分後の再改名は、単体で見れば笑い話に見えるかもしれません。しかしここで見るべき
だったのは、名前を決め切れなかったのは、対象の責務を決め切れていなかったからだということ
です。「信じる」「適用する」「最後に送る」「現在の状態を表す」は、どれも似て聞こえますが
別の約束です。3回作り直すまで、その違いには気づかれていませんでした。
いま振り返ると、この改名の連鎖は無駄な回り道ではなく、責務を少しずつ絞り込んでいく
過程そのものだったとも言えます。ただし、絞り込みの先に見つけた答え(shadow_modelへの
一本化)は、後で述べるように、本当に必要だった答えとは、まだ半分しか一致していません
でした。コードの中で頻繁に名前が変わる構造物を見つけたら、それは実装が下手なのではなく、
その構造物の責務そのものがまだ固まっていない徴候だと考えてよいでしょう。
shadow modelは何を解決したか
ここで一度、この一本化の成果を正当に認めておく必要があります。shadow_modelへの
統合は失敗ではありませんでした。統合前は、IME状態を知りたいときにImeBeliefStoreの
フィールド、Preconditions.ime_on、ImeApplyLatchの残骸のうちどれを見ればよいか、
コードを読むたびに追い直す必要がありました。統合後は、見るべき場所はshadow_model
ただ一つになりました。信念の置き場所が一つに定まったことで、「どのフィールドを
見れば今の状態が分かるか」というコードレビュー時の混乱は明確に解消しました。この直後、
ADR-032(Architecture Decision Record、設計判断とその理由を記録しておく文書)の
4層分離(Intent/Observation/Transition/Barrier)が確立し、「observationが
intentを直接書き換えない」という構造的な保証が生まれます。当時のメモにはこう記録されて
います。
IME 状態モデル shadow_model において、Observer 系の event (ObserverReported) は
desired_open を直接書き換えてはいけない。observations に記録するのみで、desired
を変えられるのは UserIntent のみ。
つまり、「誰が何を書き換えてよいか」という権限の問題は、この時点で確かに解けていました。
これは本書全体を通じて何度も参照される、重要な前進です。統合前後で、コード上の問いかけ方
も変わりました。
// 統合前: どのフィールドを見ればよいか自体が曖昧だった
belief.confirmed_open.unwrap_or(latch.last_value)
// 統合後: 問い合わせ先は一つだけ
shadow_model.effective_open()
問い合わせる側が「どこを見ればよいか」を判断する必要がなくなったこと自体が、統合の
価値でした。バグ報告を受けたときの調査の速さにも、この統合は直接効いています。統合前で
あれば、まず疑うべきフィールドの候補を複数思い浮かべ、一つずつ当たっていく必要が
ありました。統合後は、まずshadow_modelの状態遷移だけを追えばよく、調査の入口が
一つに絞られました。
問い合わせ先が一つになったことは、テストの書き方にも影響しました。shadow_modelが
受け取るのはIntent・Observationという値であり、返すのはeffective_open()が示す一つの
状態です。入力の列を与えて出力を確かめるという形にまとまったことで、実際のIMM32やTSFを
呼び出さずに、観測の順序を変えたときの挙動をテストコードの中だけで再現できるように
なりました。統合前のように、複数のフィールドを手で読み比べて確認する必要がなくなったのは、
コードレビューだけでなく、実機なしで書けるテストの範囲にも同じ効果をもたらしています。
同じ時期、もう一つの小さな防御も別の場所に用意されていました。非同期apply完了イベントが
古い場合に、それを黙って採用しないための仕組みです。窓口の整理券を思い浮かべてください。
番号を発行した時点と、あとで呼び出される時点とで、同じ番号かどうかを確かめれば、
別の用件の順番を誤って処理することはありません。各apply処理にも発行した瞬間の通し番号
(世代番号)を持たせておき、完了イベントが届いたときに、それが今も保留中の番号と
一致するかどうかを確認します。
fn on_apply_succeeded(&mut self, generation: u64, target: bool) {
if self.pending.as_ref().map(|p| p.generation) != Some(generation) {
log::debug!("stale apply result ignored generation={generation}");
return;
}
self.applied_open = Some(target);
self.pending = None;
}
連打的な操作、たとえばIME切り替えキーを短時間に2回押した場合の上書き事故には、この
確認だけで十分でした。ただしこの世代番号が数えていたのは
「何回目のapply命令か」であって、「どのフォーカス下で発行されたapply命令か」ではありません。
apply命令の世代とフォーカスの世代は、別の軸でした。したがって、フォーカスが変わらないまま
apply命令だけが連打される場面には効きますが、ALT+TABのようにフォーカス自体が入れ替わる
場面には、そのままでは効きませんでした。世代を数えるという発想はすでにここにあったのに、
何の世代を数えるべきかが、まだ取り違えられていたのです。
それでも5週間後、同じ問題が別の顔で現れた
しかしshadow_modelへの統合から5週間以上が経過した7月2日〜4日、冒頭で述べたALT+TABの
バグが報告されました。最初にログを見たときは、shadow_modelのどこかにまた新しい
書き込み経路が紛れ込んだのだろうと疑われました。しかし探しても、権限違反は見当たりません
でした。desired_openを書き換えていたのは、確かに正規のUserIntent経路でした。書き換えた
値そのものが、間違っていたのです。ログを遡ると、その値の出所はImmCrossProbeという
観測でした。観測の内容自体は誤っていません。誤っていたのは、その観測が届いた時点で、
もうその観測が指していたウィンドウにフォーカスが残っていなかったという事実のほうでした。
最初に打たれた対策(以下、Fix Aと呼びます)は、時間による猶予期間でした。「直前まで
shadow_onがオン相当で、かつ観測が届いてから200ミリ秒未満なら、その観測は疑わしいと
みなす」という条件です。この対策は機能しました。しかし、当時のADRに残された「残存する
制約」の記述は、この対策が抱える構造的な問題を率直に認めています。
Fix A は機能したが、時間ベース競合という構造的な問題を抱えていた:
- CPU 負荷次第で 200ms を超えてしまう可能性
- 3 箇所に同じ
shadow_on && probe_age_ms < SHADOW_GRACE_MS が複製された
- 「この観測は信用できるか」という判断がコードベースに分散した
この3行を読んだとき、既視感で背筋が寒くなりました。「3箇所に複製された」「判断が
コードベースに分散した」という言い回しは、5月にBeliefStoreを作った動機とまったく
同じ言葉でした。今度は信念の置き場所ではなく、信念の鮮度をどう判定するかという
ロジックが、同じように複製され、分散していたのです。5月には「今のIME状態はこうだ」と
申告する経路が複数箇所に散らばっていたのに対し、7月には「この観測は今も信じてよいか」を
判定するロジックが複数箇所に散らばっていました。対象が変わっただけで、構造は同じ形を
していたのです。
ここで露わになったのは、痛烈な事実でした。3回の改名を重ねて解決したはずの
「信念の分散」という問題が、形を変えて戻ってきていたのです。今度は信念の置き場所ではなく、
「この観測を信じてよいかという判定」が3箇所に複製されていました。集約すればSSOT問題は
解決するという当初の仮説は、不十分だったのです。単一の場所に集めることと、古い観測を
弾くことは、まったく別の問題でした。前者を3回やり直しても、後者は5月29日の時点でまだ
何一つ解決していなかったのです。
Fix Aが動いていた数日間は、一度「これで直った」ように見えました。ALT+TABを繰り返しても
再現しなくなったからです。しかし、この安心は正しくありませんでした。直ったのは
「200ミリ秒以内に古い観測が届く場合」だけであり、それより遅れて届く場合や、逆に本当に
新しい観測を誤って疑わしいと判定してしまう場合は、まだ手つかずのままでした。Fix Aは
症状を隠しただけで、原理を説明していなかったのです。
正直なところ、このときの落胆は小さくありませんでした。shadow_modelへの統合を終えた
時点では、信念の管理はこれで整理し切ったと考えられていたからです。しかし5週間後に
現れたバグは、整理が終わっていたのは「信念をどこに置くか」だけであり、「その信念を
いつまで信じてよいか」は最初から範囲の外にあったことを教えてくれました。
SSOTが答えられなかった問い
shadow_modelは「システムの現在状態は何か」という問いには、確かに答えられるように
なりました。しかし、そのために必要なもう一つの問いには、まだ答えられていませんでした。
その信念はどのフォーカス世界で得られたものなのか。そして、そのフォーカス世界は、いまも
まだ続いているのか。
probe_age_ms < 200msという条件は、この問いに対する近似的な答えでした。「最近届いた
観測なら、たぶん同じ世界のものだろう」という賭けです。ADR-077に残された比較の記述は、
この近似がなぜ壊れやすいかを言い当てています。
| 旧 shadow grace | 新 epoch 照合 |
|---|
| 判定基準 | probe_age < 200ms(近似) | フォーカスが「同じ」か(正確) |
| CPU 負荷時 | 200ms 超で素通りのリスク | 時間に無関係 |
| 重複コード | 3 箇所にコピー | 1 箇所に集約 |
| 診断 | なし | 棄却理由を記録するカウンタあり |
時間は連続量であり、CPU負荷やメッセージキューの遅延によっていくらでも伸び縮みします。
「200ミリ秒未満」という境界線は、忙しいマシンの上ではあっという間に踏み越えられて
しまいます。
7月4日06:58、604cf99で決定打が投入されました。
feat(probe-admission): FocusEpoch + ImmLikeTicket で観測受理層を新設
probe の spawn 時にフォーカスエポック(u64)をキャプチャし、完了時に照合することで
「spawn 後にフォーカスが変わったか」を時間ベースの競合なしに正確に判定できる
probe_admission モジュールを追加。
ここで発想が変わりました。「鮮度」は経過時間の近似ではなく、同一の文脈(フォーカス)の下で
発行されたかという、離散的な世代の一致として扱われるようになったのです。時間を数えるのを
やめて世代(整数)を照合するように変えた瞬間、問題は近似ではなく正確な判定になりました。
ただし、この受理層がすべての観測経路を覆ったわけではありません。TSF・GJI・HwndCache
経由で届く一部の観測は、この時点でもエポック照合の対象外のまま残され、代わりに
3000ミリ秒という時間ベースの鮮度ウィンドウで足切りする設計にとどまっています。
「FocusEpochによって鮮度の問題はすべて解けた」と言い切るのは、まだ早いのです。
この転換の実装は次章以降で扱いますが、ここで確認しておくべきなのは、shadow_modelが
本当に足りなかったものの正体です。
SSOTは「現在何を信じるか」には答えます。しかし「その信念はどのフォーカス世界で得たものか」
「まだその世界が続いているか」には、そもそも答える設計になっていませんでした。信念を
一か所へ集めることは、文脈の世代を記録することの代わりにはならなかったのです。
この問いが見落とされやすいのには理由があります。コードレビューの場でshadow_modelを
見せられれば、誰もが「信念は一箇所に集まっている」ことを確認できます。しかし「この信念が
どのフォーカス世界の産物か」は、shadow_modelの型定義を眺めるだけでは分かりません。
欠けているものは、フィールドの不足としては見えず、フィールドが記録していない時間軸として
しか現れないからです。存在しないフィールドの不在に気づくのは、存在するフィールドの誤りに
気づくよりも、はるかに難しいことでした。
具体的に考えると分かりやすくなります。エディタAにフォーカスがある間に観測処理を起動し、
その処理がまだ完了しないうちに、ユーザーがALT+TABでエディタBへ切り替えたとします。
時間ベースの判定は「観測が届くまでに何ミリ秒かかったか」しか見ません。届くのが速ければ、
それがエディタAの世界のものであってもエディタBの世界のものであっても、区別せずに採用
します。しかし本来知りたいのは経過時間ではなく、「その観測はエディタAの世界とエディタB
の世界のどちらに属するか」という、離散的な所属の問題でした。時間はこの所属を表現する
ための、不完全な代役に過ぎなかったのです。
振り返ると、非同期apply完了のための世代照合(前節のコード例)も、同じ不足を抱えていました。
あの世代番号は「何回目の命令か」を正確に数えていましたが、「どの文脈で発行された命令か」
は数えていませんでした。命令の世代とフォーカスの世代は別の数え方であり、片方を照合できて
いるからといって、もう片方が照合できているとは限りません。集約と改名が繰り返された間、
本当に足りなかったのは置き場所ではなく、この「どの世代を数えるべきか」という設計判断
そのものだったのです。
Physical AIへの接続
鮮度は一度の検査で終わる属性ではない
shadow_modelが答えられなかった問いは、ロボティクスの世界では以前から知られている
問題と同じ形をしています。ロボットのセンサーが返す値も、届いた時点ではすでに過去の
ものです。遅れて届く観測(delayed measurement)や、順序が入れ替わって届く観測
(out-of-sequence measurement)を扱う議論は、この分野で以前から蓄積されています。センサー
融合の実装で「信念(belief)を1つの状態推定器へ集約する」設計自体は、ロボティクスでも
古くから行われてきました。しかしそこでも、集約した状態推定器が「いつの観測に基づいて
いるか」を保持できていなければ、古いセンサー値が新しい状況を上書きする同じ事故が起こり
えます。信念を集約することと、集約した信念の鮮度を管理することは、ロボティクスにおいても
別の設計判断として扱われるべきものです。
したがって、本書がここで主張したいのは「遅れて届く観測がある」という事実そのものでは
ありません。それは既知の問題です。本書が示したいのは、「この観測は今の文脈でまだ有効か」
という判定を、時間の近似ではなく、世代の照合という離散的で正確な仕組みに置き換えた
という、実装レベルの規律です。信念を一か所に集めることと、観測の所属世代を照合すること
は、似た言葉で語られがちですが、まったく別の設計判断です。この2つを混同したまま3回の
改名を重ねたことが、本章で辿った回り道の正体でした。
もう一点、注意すべき限界があります。観測を受理する層で世代を照合できても、受理した観測を
実際に不可逆な作用へつなげるまでの間に、もう一度世代が変わってしまう可能性は、この設計
だけでは塞がれていません。鮮度は一度検査すれば終わる属性ではなく、不可逆な作用へ進む
すべての境界で、繰り返し再検証されるべき契約です。この規律は、観測を受け取る側だけでなく、
観測に基づいて実際に何かを実行する側にも同じ強度で適用されて初めて意味を持ちます。信念を
集約する設計だけを見て「もう鮮度の問題は解決した」と判断してしまうと、この2つ目の適用を
見落としたまま次の作業に進んでしまいます。この急所については、後の章であらためて扱います。
設計原則
観測には「いつのものか」が付いていないと、過去が現在を書き換える。
この原則が適用できるのは、非同期に届く観測や応答が、発行から到着までの間に、判断の
前提となる文脈(フォーカス・セッション・接続先など)が変わりうる場面です。単一プロセス内の
同期処理のように、発行と適用の間に文脈が変わりえない場面では、この原則を適用する必要は
ありません。逆に、文脈が変わりうるのに「めったに変わらないから」という理由でこの原則を
省略すると、本章で見たように、発生頻度が低いぶん原因の特定がかえって遅れます。
実装の形としては、観測を発行した時点で文脈を表す世代番号を記録し、観測を採用する時点で
現在の世代番号と照合します。一致しなければ棄却します。BeliefStore→ImeApplyLatch→
LastAppliedImeStateという3回の改名は、「どこに集約するか」を探す試みでした。しかし本当に
必要だったのは集約場所ではなく、この観測は今の文脈でまだ有効かを照合する仕組みでした。
時間(ミリ秒)で近似すると、負荷や遅延で簡単に破れます。世代(整数)で照合すると、時間に
依存せず正確に判定できます。
ただしこの原則は、観測を受け取る場面だけを保証します。観測を受理した後、実際に不可逆な
作用へ進むまでの間に、文脈がもう一度変わってしまう場合があります。その間隙をこの原則は
まだ塞いでいません。
第7章:敵プロセスを外側から観測する

型で状態を表現しても、それだけでは足りないことがあります。観測する経路そのものが、外部から汚染されている場合です。第II部までは、Windowsが返す情報をどう解釈するかを問題にしてきました。第III部では、判定条件を増やす、いわゆる「パッチ」を重ねるだけでは解けなかった問題を三つ扱います。
最初にぶつかったのは、状態を一切外に出さない相手でした。相手は自分のコードではなく、競合する別の日本語入力エンジン、GJI(Google Japanese Input、Google 日本語入力)です。公式なAPIで内部状態を尋ねる手段は、最初から存在しませんでした。
awaseは、GJIが実際に活性化したかどうかを知る必要がありました。両者が同じ物理キーの入力を奪い合う場面があったからです。知る手段がないまま送信を続けると、文字が正しく合成されない事故が起きました。
第7章から第9章までが、この第III部にあたります。局所的な対策を積み重ねる代わりに、問題の見方そのものを変える場面を、三つの章に分けて描きます。
GJIは、活性化したかどうかを教えてくれなかった
待ち時間をあと200ミリ秒延ばすだけなら、設定を1行書き換えれば済むはずでした。実際には、そこから三つの方式を試し、四つの案を退けるまでに数週間を要しました。最後に残った答えは、待ち時間の調整ではありませんでした。
事の発端は、ChromeやBrave、Edgeのようなブラウザで起きた症状です。IMEをOFFからONへ切り替えた直後にローマ字を入力すると、「kおのなかで」のような部分的なリテラル化が発生しました。日本語入力を始めるたびに起きうる、ありふれた操作の直後の事故でした。
原因の構造は、こうでした。これらのブラウザはVKモードで動作し、物理キーコードをそのまま受け取ります。GJIのcomposition contextは、活性化前の状態(cold状態)ではVKキーを素通りさせてしまいます。GJIをVK_DBE_HIRAGANA(16進数で0xF2にあたる、IME専用の仮想キーコード。コミット履歴では略して「F2」と呼ばれるが、キーボード最上段のファンクションキーF2とは別物)で活性化させるまでには、数百ミリ秒の遅延がありました。この遅延の間に最初のVKキーが届くと、ひらがなに変換されないままリテラル文字として確定してしまいました。awase自身のキー処理はほぼ即座に終わるため、遅いのはこちらではなく相手のGJIでした。
この事故を防ぐには、GJIが「いつ活性化を終えたか」を知る必要がありました。しかし、GJIは競合する別会社の製品であり、内部状態を教えてくれる公式なインターフェースはありません。当初検討したのは、三つの方向でした。DLLフックは、対象のプロセスに自分の書いたコードを注入し、内部の関数呼び出しを横から書き換えて横取りする技術です。COM経由の問い合わせは、TSF(Text Services Framework、IMM32の後継としてWindowsが提供するIME連携の仕組み)を通じて、外部のプロセスに直接いまの状態を尋ねる技術です。名前付きパイプによるIPCの盗聴は、別々のプロセス同士がやり取りする内部通信(プロセス間通信、IPC)を、外側から覗き見る技術です。
実際に手を動かしたのは、この三つの方向だけではありませんでした。単純な待ち時間の調整も含めて、四つの具体案を実装し、実機で確認しました。結果は、侵襲性・安定性・バージョン依存・実装コストという四つの軸で見ると、それぞれ異なる理由ですべて却下でした。
| 案 | 決定的だった弱点 |
|---|
| 待機時間の延長(300ミリ秒→500ミリ秒) | 安定性。Windows Terminalの初期化遅延(344ミリ秒)に追随できず、時定数の調整自体が競合条件でした |
| DLLフックによるメッセージ傍受(WM_IME_STARTCOMPOSITION) | 侵襲性。DLLインジェクションが前提になり、アンチウイルスの誤検知リスクが大きすぎました |
| COM経由のクロスプロセス問い合わせ(ImmGetCompositionString) | バージョン依存。TSFネイティブなアプリでは常に0が返り、実験では952ミリ秒遅れて反映されることもありました |
| I/Oカウンタを時刻で見る方法(GetProcessIoCounters、時間ベース) | 実装コスト。ChromeのTSFコンテキスト更新はCOMイベント経由で届くため、観測タイミングとの競合が消えませんでした |
四つの案は、それぞれ実機で次のように試され、次の理由で退けられました。
待機時間の延長では、GJIの活性化にかかる数百ミリ秒の遅延を、待ち時間そのものを300ミリ秒から500ミリ秒に延ばすことで吸収しようとしました。ところがWindows Terminalというターミナルアプリ自体の初期化にも344ミリ秒かかっており、GJI側の遅延とアプリ側の遅延が重なる場面がありました。待ち時間という一つの定数で両方の遅延を吸収しようとすると、必要な長さが環境ごとに変わってしまいます。定数を延ばす作業自体が、「どこまで延ばせば足りるか分からない」という競合条件になりました。
DLLフックでは、GJIのメッセージ処理関数そのものに割り込み、WM_IME_STARTCOMPOSITIONというメッセージが処理される瞬間を直接つかもうとしました。狙いどおりに動けば、活性化の瞬間を正確に検出できたはずです。しかしこの方式は、別プロセスへコードを注入するという、アンチウイルスやEDR製品がマルウェアの典型的な挙動として検知する操作そのものでした。ユーザーが安心してインストールする日本語入力エンジンが、誤検知の標的になるリスクは受け入れられませんでした。
COM経由の問い合わせでは、ImmGetCompositionStringというAPIを使い、GJIに直接「いまの合成文字列」を尋ねようとしました。このAPIは、IMM32(Input Method Manager、Windowsが長く提供してきたIME連携の旧来API)が提供するものです。ところがTSFネイティブなアプリでは、この問い合わせは常に0を返し、何も分かりませんでした。値が返る場合でも、実験では実際の状態変化から952ミリ秒遅れて反映されることがありました。問い合わせても正しい値が返るとは限らず、返ったとしても遅すぎるという二重の不確実性を抱えていました。
I/Oカウンタを時刻ベースで見る方法では、GetProcessIoCounters()というAPI自体は、後に採用する方式と同じものをすでに使っていました。ただしこのときは、I/Oが最後に発生した時刻を見て、そこから一定時間だけ待機を延長するという使い方でした。ところがChromeは、自分自身のTSFコンテキスト更新をCOMの非同期イベントとして別経路で届けてきます。I/Oカウンタの観測とCOMイベントの到着は、どちらも非同期に起きるため、どちらが先に届くかを保証できませんでした。
四つの案に共通していたのは、GJI自身に何かを尋ねるか、相手の出力タイミングを予測しようとする点でした。相手に依存する限り、侵襲性か不安定さのどちらかを引き受けることになりました。この時点で、すでに数週間が経過していました。
四つ目の案は、最終的に採用する仕組みとよく似た材料を使っていました。ただし、見ていた指標が違いました。
見ていたのは時刻だった、必要だったのはバイト数だった
GetProcessIoCounters()というAPIは、対象プロセスのI/O統計を外部から読み取れます。これ自体は、失敗した四つ目の案ですでに使っていた材料でした。失敗の原因は、この値を時刻の経過として扱っていたことにありました。
この転換は、Chromeの内部実装を新たに解析した結果ではありませんでした。すでに手元にあった同じAPIの返り値を、これまでとは違う軸で読み直しただけでした。視点を変えて、時刻ではなくバイト数の差分を見ると、結果が変わりました。WriteTransferCount(書き込み転送バイト数)の差分が、GJIが「モード切替キー」を処理したのか、「実際の文字合成」を処理したのかを、明確に区別していたのです。
実際の差分がどれくらいの大きさになるのか、実機で5サンプルを取って確かめた記録が残っています。
| 種別 | VK_A送信後のGJI write差分 | 判定 |
|---|
| cold状態のChrome(リテラル'a'が出る) | +300バイト(w_KB=+0.3) | timeout→SacrificialResend |
| warm状態のChrome(合成'あ'になる) | +400バイト(w_KB=+0.4) | composition-confirmed |
中間の350バイトを、活性化判定のしきい値としました。しきい値を低く取りすぎれば、「F2」だけの操作をwarmと誤判定します。逆に高く取りすぎれば、本物の合成が起きているのにcoldのまま見逃します。350バイトという値は、この両側の失敗を避けるために選ばれた境界でした。「F2」単体の送信では、write_bytesは+0.0バイトのまま変化しませんでした。もし時刻ベースのgji_last_io_msを使っていたら、「F2」の処理だけで誤検知していたはずです。バイト数ベースに転換したことで、この誤検知を避けられました。
350バイトという数字は、GJIのこのバージョン・この環境で観測された値であり、普遍的な定数ではありません。GJIの実装が変われば、しきい値も引き直す必要があります。採用したコードでは、この値を固定の判定基準としてではなく、warm状態とcold状態を分ける経験的な境界として扱っています。
この差分も、第5章・第6章で見た意味でのObservation(観測)の一種にすぎません。GJIが活性化したという事実を直接確定させるのではなく、beliefを更新するための一つの手がかりとして扱いました。観測は、それだけでは真実になりません。
本物の入力を、観測のために使う
しきい値が決まっても、それだけでは活性化を確認する手段になりません。GJIに向けて、判定用の何かを送る必要がありました。ここで採った方法は、専用のプローブを新たに作らないというものでした。
採用したのは、捨て打ち機能と呼ぶ技法です。必要な操作、つまり本物のキー送信そのものを、観測のためのプローブとして転用します。捨て打ちという名前は、実際には使わない捨て文字を、本当は打つ必要のない一手として送ることに由来します。後述するADR-062は、この捨て打ちすら行わない、さらに徹底した形へ進みます。
流れはこうです。cold状態を検知すると、まずVK_AとBSをひとまとめに送信します。GJIがこれを合成しようとすればwrite_bytesが増えるため、その差分を観測します。350バイトを超えていればwarm状態への遷移とみなし、いったん捨てた本物のローマ字を送り直します。届かなければタイムアウトとみなし、GJIをIME OFF/ONで強制的にリセットしてから再試行します。

VK_AとBS(バックスペース)を、あえて同じSendInputのバッチで送っています。理由はADR-048にこう記録されています。別のSendInput呼び出しに分けると、Chromeがフレームを描画するタイミングで捨て文字の'a'が一瞬画面に表示されてしまいます。同一バッチであればOSがイベントキューに連続して積むため、Chromeは描画の前にVK_AとBSの両方を処理し、画面には何も現れません。
観測の基準値(baseline)を取得するタイミングにも、見落としがありました。commit 26bc0feの記録によれば、VK_A送信後にbaselineを取得すると、cold状態のChromeがすでに書き込んだ+300バイトごとbaselineに吸収されてしまい、差分が0に見えてしまいます。baselineはVK_A送信前に取得しなければなりませんでした。単純な前後関係の取り違えが、しきい値判定そのものを無効化してしまう例です。
この仕組みは、当初すべてのcold-startに適用していました。ところがcommit d02ec44の記録によれば、TSFモード以外のアプリでは、VK_Aを送ってもHIDEイベントが来ない、composition_was_seenフラグが立たないといった不具合が起き、かえって性能が落ちました。最終的には、長時間cold状態が続き、かつTSFモードで動作しているアプリに限って適用する形に絞り込んでいます。この技法は、対象の状態モデルが一致する範囲でしか有効ではありません。
絞り込みはここで終わりませんでした。6月30日、6分の間に方針が二度反転しています。06時28分のcommit 6c1732dは、捨て打ち(VK_A+BSを送る方式)をやめ、IME制御キーであるVK_IME_OFF→ONだけを送る方式へ、対象アプリすべてを統一しました。理由はコミットメッセージにこう記されています。
vim等のターミナルアプリではVK_Aがcold時にアプリへ届き誤動作する。VK_IME_OFF→VK_IME_ONはIME制御キーのためvimのキーバインドに干渉しない。
VK_Aという文字キーは、vimのノーマルモードではappendコマンドに割り当てられています。Windows Terminalではvimが動くこともあるため、捨て文字のつもりで送ったVK_Aが、cold状態のためにIMEを素通りしてターミナルへ直接届き、vim側の編集コマンドとして誤って実行されてしまう可能性がありました。IME制御キーであるVK_IME_OFF/ONにはこの種の衝突がありません。
ところがその6分後、06時34分のcommit 22c3905は、Chromeだけを元のVK_A+BS方式へ戻しています。
ChromeでVK_IME_OFFがTSF contextを壊す問題を修正(過去検証済み知見)。Chrome: VK_A+BS(元の方式)。Chrome内でvimが動くケースは稀でありVK_Aのvim問題は許容。
普段のIME ON/OFF切り替えではVK_IME_ON/OFFはChromeでも問題なく動きます。ところがcold-start直後、コンポジションがまだ確立していない一瞬にVK_IME_OFF→ONを送ると、ChromeのTSFコンテキストそのものを壊してしまうという、より限定的な問題がありました。vimとの衝突というリスクと、TSFコンテキスト崩壊というリスクを比べ、Chromeに関してはvim問題を許容する側を選んだことになります。
こうして捨て打ち機能は、Chrome+GJI(GJIが活性化している場合にのみ観測が必要になるため)の組み合わせにだけ残る技法になりました。ADR-063によれば、競合するIMEがMS-IMEの場合はTSFコンテキストが常にwarm状態であるため、そもそもこの種のプローブ自体が不要です。Windows Terminalのような他のTSFネイティブアプリは、IME制御キーだけを使うVK_IME_OFF→ON方式に置き換わっています。同じ問題に見えた症状が、対象アプリの内部実装次第で、最後まで同じ解法を共有できなかったことになります。
観測を担う部分の実装は、次のようになっています。
fn sample(&mut self) -> Option<GjiIoDelta> {
let counters = get_process_io_counters(self.handle)?;
let write_bytes = counters.WriteTransferCount
.saturating_sub(self.last_write_bytes);
// 他4指標(読み込み回数等)も同様に差分化するが、判定に使うのは write_bytes だけ
Some(GjiIoDelta::from_write_bytes(write_bytes))
}
GjiMonitor::sampleが返すGjiIoDeltaは、読み込み回数・書き込み回数・その他の回数、読み込みバイト数・書き込みバイト数という五つの差分をまとめて持っています。活性化の判定に使っているのは、このうちwrite_bytes一つだけです。時刻も併せて記録していますが、判定そのものには使っていません。呼び出し側は、この差分が350バイトを超えたかどうかだけを見ればよく、GJI内部の実装詳細を知る必要はありません。三つの方式を試し、四つの案を退けるのに数週間を費やした末に見つかった答えは、10行に満たない差分計算の関数でした。
この関数を二つに分けたことにも意味があります。実際のOS呼び出し(get_process_io_counters)はsampleの中に閉じ込められ、しきい値との比較は、その外側の純粋な関数として書けます。本物のGJIプロセスを起動しなくても、GjiIoDeltaに任意のバイト数を詰めて渡すだけで、「349バイトならcold、350バイトならwarm」という境界条件をテストコードの中で確認できます。実機を用意しなければ確かめられない部分(OS呼び出し)と、値さえあれば確かめられる部分(しきい値判定)を切り分けておいたことが、後から350という値を調整し直す作業を軽くしていました。
同じ観測方法に、二度たどり着いた
この技法は、6月14日から24日にかけてのADR-048で最初に記録されました。Chromeのcold-start問題を解くための、SacrificialWarmup機構としてです。
同じ着想が、6月25日のADR-062でも独立に現れました。今度の対象は、まだ学習していない種類のTSFモードを自動判定するUnicodeLiteralObserverFsmでした。ローマ字を送信してから100ミリ秒待ち、GJIのwriteがあるかどうかで判定する形に、同じ発想を再利用していました。
ADR-062は、ADR-048よりもう一歩先に進んでいました。ADR-048は捨て文字のVK_Aを新たに送っていましたが、ADR-062はそれすら送っていません。どのみち送るはずだった本物のローマ字自体を、プローブとして転用していました。専用の観測用入力を足すこと自体が、新しい非決定性の発生源になり得るという判断です。
ADR-062には、その理由がこう記録されています。
gji_last_io_ms(時刻ベース)ではなくgji_write_bytes(バイト数ベース)を使う理由: F2(モード切り替え)もI/O時刻を更新するため誤検知するが、WriteTransferCountはF2で増加しない(w_KB=+0.0)ため分離できる。
10日あまり離れた二つの問題が、同じ観測方法にたどり着きました。偶然の一致ではなく、GJIの内部が見えないこと、時刻では区別できないことという同じ制約が、別の問題でも繰り返し現れたためだと考えられます。その場しのぎの奇策のように見えましたが、別の問題でも同じ観測方法へ戻ってきました。
この技法をawase固有の思いつきだと考えるのは、正確ではありません。電気やガス、水道の使用量を外から見るだけで、部屋の中で誰かが何をしているかを言い当てる、という考え方に近いものです。実際、プロセスのI/Oカウンタを操作の前後で比較し、相手の挙動を推測するという手法自体は、2013年ごろに広まったアンチデバッグ手法の解説記事(セキュリティ研究者Peter Ferrieによる技法集にも収録されています)に、ほぼ同じ仕組みで登場します。ただしそちらの用途は、デバッガ(プログラムの動作を一行ずつ追跡するツール)に自分が見張られていないかを、プログラム自身が確かめる、いわば「見張られていないかを自分で確認する」内向きの自己防衛でした。awaseが行ったのは、この技法を外向きに——敵対する相手ではなく、同じ画面を取り合っている隣人のようなプロセスの様子を、相手に気づかれず外から窺うために——転用したことです。
「これから行う必要な操作に、観測を便乗させる」という発想にも先例があります。郵便配達員が、わざわざ玄関のチャイムを鳴らして在宅かどうかを尋ねる代わりに、前日の郵便物がポストから無くなっているかどうかを見るだけで在宅を推測するようなものです。ネットワークの世界にも、専用の確認要求を送らず、すでに発生している通常の通信に相乗りして相手の状態を推測する、受動的ヘルスチェックという手法があります(F5やNGINXのようなロードバランサ製品が採用しています)。捨て打ち機能、そしてADR-062がたどり着いた「専用のプローブすら送らない」という徹底も、この発想の系譜に連なるものだと言えます。
対象プロセスが何をしたかを、外から言い当てる話でした。まだ手つかずだったのは、逆方向の問題です。こちらが送ったキーが、相手のアプリケーションにとって本物として解釈されるか、そうでないかという問題でした。この問題は、相手の状態を観測することではなく、送信する経路そのものの設計を必要としました。
設計原則
原則: 相手が状態を公開しないなら、相手に必要な操作をさせ、その副作用を数量として観測する。
適用条件: 対象プロセスに公式な状態取得手段がなく、代わりに何らかのシステムリソース(I/O・CPU・メモリ等)の変化を外部から読み取れる場合に使う。判定に使う指標は、目的の操作とそれ以外の操作を区別できるものを選ぶ(本章では時刻でなくバイト数)。対象の状態が短時間で頻繁に切り替わる場合ほど、時刻ではなく量で判定できる指標を優先する。
実装の形: 本物の操作にプローブを便乗させ、専用の観測用入力を新たに作らない。しきい値は実測した複数サンプルから中間値として決め、コード上は経験的な境界として扱う。判定に使う値の取得順序(いつ基準値を取るか)も、しきい値そのものと同じくらい注意して決める。
限界: しきい値は対象プロセスの具体的なバージョン・環境に依存し、普遍的な定数として扱えない。対象の実装が変われば、しきい値の再計測が必要になる。対象が想定していない状態にまで一律に適用すると、判定の空振りによってかえって性能が落ちることがある。
第8章:フィルタでは勝てない

2026年5月27日、21時。手元のコミットログには、LINEというアプリ名だけが繰り返し現れていました。
LINEはQtで作られたチャットアプリで、awaseの利用者の間でも特に厄介な相手として知られていました。
物理的なKANJIキーを押すと、LINEの内部で偽のVK_F3・VK_F4トグルイベントが生成され、それが本物の
物理イベントと区別できなくなるという症状です。この章のVK_F3・VK_F4は、キーボード最上段にある
ファンクションキーのF3・F4(Windows APIが定義する本来のVK_F3・VK_F4)とは別物です。実際に
送受信されていたのは、IMEを半角/全角に切り替えるための専用の仮想キーコードVK_DBE_SBCSCHAR・
VK_DBE_DBCSCHARで、その値がたまたま16進数で0xF3・0xF4になります。当時のコミットメッセージが
この2つを略して「VK_F3」「VK_F4」と書いており、この章でも読みやすさのためその記法をそのまま
使います。利用者から見れば、親指キーを一度押しただけなのに、
内部では二回のトグルとして扱われてしまう現象でした。しかも毎回同じ形で再現するわけではなく、
直前にどんな操作をしていたか、どのタイミングでキーを離したかによって、現れ方が変わりました。
その揺れの一つひとつを、その晩は順番に踏むことになります。
この夜、私はこの症状に4回フィルタを重ね、4回とも再発させました。それぞれの修正は、直前の修正が
見落としていた一点をピンポイントで塞ぐものでした。ところが塞ぐたびに、次の一点が現れました。
翌日未明、私はフィルタを強化することそのものをやめ、経路そのものを断つという、まったく別の
問いに切り替えました。この章で追うのは、その一晩の攻防と、問いの立て方が変わった瞬間です。
これまでの章では、Windowsが返す観測が信用できないという問題を扱ってきました。届いた値を
そのまま信じず、belief(信念)として一段クッションを置く、という考え方です。しかし今回の
相手はOS自身ではなく、隣で動く別のアプリでした。LINEが生成する偽イベントは、Windowsの
API仕様の問題ではなく、awaseの物理キー入力とLINE自身の内部処理が、同じ経路の上で
衝突しているために起きていました。観測を疑うだけでは足りず、経路そのものを見直す必要が
あったのです。
二一時、「防止」は一度で終わるはずでした
最初のコミットd99e3f1(21:00)のメッセージは「ImmCross asyncパスで楽観的latchを設定しLINEの
spurious VK_F3を防止」でした。ImmCrossというのは、awaseがIME状態をIMM32経由で相手プロセスに
直接書き込む方式を使うプロファイルの名前で、LINEはこの方式に分類されていました。IMM32とは、
Windowsに昔からあるIME連携の仕組みで、外部のプログラムが他のアプリのIME状態を直接読み書き
できるAPIです。「asyncパス」
とは、その書き込みを相手プロセスの応答を待たずに進める経路を指します。応答を待たない分、
自分の側の状態と相手の側の状態がずれる余地も大きくなります。
私は当時、偽のVK_F3が発生するのは自分がKANJI操作を送った直後の短い時間帯に限られると
考えていました。だから、その直後だけ一度だけ抑制用のlatch(かんぬき)を立てれば十分だろうという
仮説を立てました。latchという名前が示す通り、この修正は「一度きりの短い窓を閉じる」という
発想に基づいていました。窓の外側で何かが起きるとは、このときはまだ考えていませんでした。
自分が送った操作の直後だけを疑い、それ以外の時間帯は届いた値をそのまま信じてよいという
考え方は、それまでの章で組み立ててきたbeliefの発想の延長線上にありました。
この仮説はすぐに崩れました。1時間半後の22時35分、77ccf34「ImmCrossアプリのKANJI KeyUpで
spurious VK_F3を防止」というコミットが積まれています。latchが想定していなかったタイミング、
具体的にはKeyUpの扱いでも、同じ形の偽イベントが発生していたということです。KeyDownの直後だけを
見張っていたつもりが、KeyUpという別の入口が空いたままだったのです。私は対象をKeyUpにまで広げ、
これで発生源はようやく塞がったと考えました。
一晩で、同じ言葉が四回繰り返されました
しかし22時35分の修正も長くは持ちませんでした。日付が変わった0時05分、e890a26
「ImmCross asyncパスでVK_F4/F3 KeyUpをshadow-suppressしLINEのspurious VK_F3を防止」という
コミットが積まれます。今度はVK_F3だけでなくVK_F4も抑制対象に加え、shadow状態(awaseが自前で
保持するIME状態の推定値)を経由する形に作り直しました。対象となるVKコードを一つ増やし、
判定の経由地点も一つ増やしたことになります。この時点で、深夜の作業はようやく終わったように
見えました。VK_F3・VK_F4の両方、Down・Upの両方を押さえたつもりだったからです。
ところがその1時間半後、1時39分にf84c74b「VK_F3 Down ON→OFF時にVK_F4 Upもshadow-suppressして
LINE spuriousチェーンを断つ」というコミットが積まれます。VK_F3がONからOFFへ切り替わる、その
一つの遷移の段階だけ、抑制の対象から漏れていたということです。ここで初めて「断つ」という言葉が
コミットメッセージに現れました。しかし実際にチャネルを断つ処理が書かれたのは、この次のコミット
でした。「断つ」と書きながら、まだ何も構造的には断っていなかったのです。
四段階の背後にあった実装は、shadow_toggle_suppressed_vksという許可リスト(HashSet)への
登録でした。抑制したいVKコードが見つかるたびに、このリストへ一つずつ追加していく仕組みです。
リストへ追加するという実装の形そのものが、次のエッジケースが見つかるたびにまた一つ追加する、
という運用を招いていました。四つのコミットを並べると、同じ形の失敗が繰り返されていたことが
分かります。それぞれの修正が何を本物のイベントだと仮定し、LINEがどの反例でその仮定を破ったかを
見ると、範囲の広げ方には共通の型があります。
| 時刻 | コミット | 何を本物のイベントだと仮定したか | LINEはどの反例でそれを破ったか |
|---|
| 21:00 | d99e3f1 | 自分がKANJI操作を送った直後の短い時間帯だけ、偽のVK_F3が来る | latchが想定していないタイミングでも同じ偽イベントが生成された |
| 22:35 | 77ccf34 | KeyUpの扱いを直せば、発生源はそれで塞げる | shadow状態を経由しない経路でも、同型の偽イベントが観測された |
| 00:05 | e890a26 | VK_F4もshadow-suppressの対象に広げれば、経路を網羅できる | ON→OFFへの遷移という一段階だけ、抑制対象から漏れていた |
| 01:39 | f84c74b | 漏れていた遷移さえ塞げば、これでチェーンは断てる | 「断つ」と書きながら、実際にはまだ何も構造的に断っていなかった |
四つの仮説を並べると、狭めていた軸がそれぞれ違うことが分かります。1回目は「いつ」(操作直後の
短い時間帯)を絞り、2回目は「どの操作」(KeyDownかKeyUpか)を絞り、3回目は「どのキー」(VK_F3か
VK_F4か)を絞り、4回目は「どの遷移段階」(ONからOFFへの切り替わりの瞬間)を絞っていました。
毎回、直前の反例が突いてきた一点だけをふさぎ、その他の前提はそのまま引き継いでいたのです。
この4つの仮説を通して見ると、一つ塞ぐたびに次の穴が現れ、最後に経路そのものを断つ決断へ至る流れが見えます。

四つのコミットメッセージの語尾を追うと、「防止」「防止」「防止」ときて、最後だけ「断つ」に
変わっています。この言葉の変化そのものが、フィルタを重ねるだけでは足りないと私自身が
気づき始めていたことを示しています。同時に、対象範囲はVK_F3単体→KeyUp→VK_F4→特定の遷移と、
一つの修正のたびに一段ずつ広がっています。これは新しい原因が次々に見つかったというより、
最初の仮説の立て方そのものが狭すぎたことを、後から一つずつ思い知らされていた形に近いものでした。
この種の修正は、モグラ叩きと呼ばれる形をしています。一匹叩くたびに、その手応えだけを見れば
成功しています。実際、21時・22時35分・0時05分のいずれの時点でも、私はその晩の作業が
終わったと感じていました。しかし次のモグラが現れる場所は、前の一匹が出た穴のすぐ隣でした。
四段階を終えた時点で私が向き合っていたのは、個々の穴ではなく、モグラがいくらでも出てこられる
盤面そのものだったのです。盤面を変えない限り、フィルタをどれだけ足しても追いつきません。
フィルタをやめ、経路を渡さないことにしました
四回目の修正から20分後、1時59分に積まれた08b8661のメッセージは
「ImmCrossアプリでも物理KANJIをConsumeしspurious VK_F3/F4連鎖を遮断」でした。ここで問いの
立て方そのものを変えました。「届いたイベントが本物か偽物かを、どう見分けるか」ではなく、
「そもそも物理KANJIキーをLINEに渡さなければ、偽イベントを生成する材料自体がなくなるのでは
ないか」という問いです。前の4段階が全て「見分ける」側の問いだったのに対し、これは
「渡す・渡さない」という別の軸の問いでした。
たとえるなら、噂を止める方法には二通りあります。広まった噂を一つずつ訂正して回るか、そもそも
噂の種になる情報自体を最初から渡さないか、です。LINEに物理KANJIキーの情報を渡さなければ、
LINEがそれをもとに独自のトグル状態を組み立てる材料自体がなくなります。
この切り替えに、劇的な発見や新しい調査は必要ありませんでした。必要だったのは、4回同じ形の
修正を積んだという事実そのものを、一歩引いて見ることだけでした。個々の修正は、その場では
どれも筋が通っていました。しかし4回目を終えた時点で並べて見ると、範囲が一段ずつ広がる
という同じ形の失敗が続いていることが分かります。20分という短い間隔は、次の一手を新しく
発見したというより、それまでの4回を並べ直した結果と考えるほうが実態に近いものでした。
key_pipeline.rsの該当箇所は次のようになりました(memory project_kanji_imecross_spurious_vk3より)。
let is_kanji_event = event.ime_relevance.shadow_action.is_some();
let suppress_physical = if profile.can_use_imm32_cross_process() {
// ImmCross: KANJI 関連 VK は Down/Up 共に Consume
is_kanji_event
} else {
// Imm32Unavailable (Chrome/Edge): 従来通り shadow_toggle 発火時のみ
shadow_toggled && !profile.should_pass_physical_key()
};
条件分岐の中身は、以前の4段階よりもむしろ単純になりました。ImmCrossプロファイルに該当する
なら、KANJI関連のVKはDownもUpも問答無用でConsume(握りつぶす)します。タイミングや遷移段階を
一つずつ数え上げる代わりに、判定そのものを一種類減らしたのです。8分後の0e364eaでは、
不要になったRegistry機構(先ほどの許可リストへの登録の仕組み)を撤去しました。
shadow_toggle_suppressed_vksというHashSetと、
それを操作するregister_shadow_toggle_suppress()・try_shadow_suppressed_keyup()という
関数群です。差分は7ファイルで+27/-89、正味62行の削減になりました。四段階かけて積み上げた
「見分ける」ための仕組みは、「渡さない」という一行の条件に置き換わった時点で不要になったのです。
この単純化は、テストのしやすさにも表れています。is_kanji_eventもsuppress_physicalも、
eventという値を受け取ってboolを返すだけの純粋な関数です。実際にLINEを起動し、物理
KANJIキーを押して確かめなくても、eventを組み立ててこの関数に渡すだけで、四段階の仮説
それぞれが見落としていたKeyUpの扱いやVK_F4の抜け漏れを、テストコードの中で再現し直せます。
四段階のフィルタが実機での再現待ちを繰り返していたのに対し、渡す・渡さないという一行の
条件は、実機なしで検証できる範囲を大きく広げました。
なぜこの転換が可能だったのかは、当時のメモfeedback_immcross_owns_kanjiに残っています。
ImmCrossプロファイルは、set_ime_open_cross_processによって相手プロセスのIME開閉状態を
すでに直接書き込んでいました。つまり、物理KANJIキーをLINE自身に渡す必要は、そもそも
なかったのです。渡さなければ、LINEが偽イベントを生成する根拠自体が消えます。フィルタを
四段階重ねている間、私は「渡した後でどう見分けるか」しか考えていませんでした。しかし
ImmCrossというプロファイル自体が、渡すかどうかを選べる立場にすでにいたのです。
許可リストへの登録という形と、プロファイルごとの真偽値一つという形の違いは、状態を持つか
持たないかの違いでもあります。shadow_toggle_suppressed_vksは、どのVKコードを抑制するかを
実行時に覚えておく必要がありました。新しいVKコードが対象に加わるたびに、登録処理を呼び、
リストの中身を増やしていく必要があったのです。一方is_kanji_eventは、そのイベント一件だけを
見て判定が終わります。前のイベントで何を登録したかを覚えておく必要がありません。四段階の
フィルタが複雑になっていった理由の一つは、判定のたびに過去の登録状態を参照する仕組みを
選んでいたことにもありました。
最終的な挙動は次の表に落ち着きました。表中のTSF(Text Services Framework)は、IMM32よりも
新しいWindowsの入力方式管理の仕組みで、Windows Terminalのような一部のアプリはこちらを使ってIME処理を
自前で完結させています。
| プロファイル | KANJI Down | KANJI Up |
|---|
| Imm32Unavailable(Chrome/Edge) | shadow_toggle後にConsume | 通過(アプリは反応しないため問題なし) |
| ImmCross(LINE/Qt) | shadow_toggle後にConsume | Consume(今回追加) |
| TsfNative(Windows Terminal) | 通過(TSFが処理) | 通過 |
ImmCrossの列だけがDown・Up両方でConsumeになっている点が、他の2プロファイルとの違いです。
同じ物理キーでも、相手アプリがIME状態をどう受け取るプロファイルかによって、渡すか渡さないかを
使い分けるという発想は、この一晩で初めて明文化されました。表の3行は偶然そろったものではなく、
「相手が物理キーを見て何をするか」をプロファイルごとに事前に決めておき、見せる・見せないを
その場の判定ではなく設計として固定した結果です。四段階のフィルタが場当たり的な条件式の
積み重ねだったのに対し、この表は一度作れば以後の判定を必要としません。
Physical AIへの接続
動作を見た別のセンサーが、それを新しい入力だと誤認する
LINEが起こしていたのは、ロボティクスの世界にも似た構造の問題です。自分の関節モーターが
発する動作音を、同じ機体のマイクが拾い、それを新しい音声コマンドだと誤認識してしまう
フィードバックループがその例です。センサーの側から見れば、モーター音も外部からの音声入力も、
波形としては区別がつきません。区別できないものを区別しようとしてフィルタの条件を
増やし続けるか、あるいはそもそもマイクにモーター音を拾わせない(機構的な遮音、あるいは
発話区間だけマイクを有効化するゲーティング)かは、awaseが一晩でたどった
「フィルタ強化からチャネル遮断へ」という経路と同じ選択です。どちらを選べるかは、
自分がそのセンサーやアクチュエータの入出力経路をどこまで所有しているかで決まります。
機体の設計段階からマイクの配置や有効化タイミングを選べるなら、遮断は現実的な選択肢に
なります。既製のセンサーモジュールを後から組み込むだけの立場であれば、遮断という選択肢は
手元になく、フィルタを強化し続けるしかありません。
境界を持たない層では、フィルタしか選べません
この問題は、awaseが最初に直面したものではありませんでした。Firefox OSのIME層である
Gaia(bug 1110030)は、合成イベントと物理イベントが区別できないという同型の症状に対し、
IsSynthesizedByTIPという判別用フラグを追加する方向、つまりフィルタを強化する方向で
対応していました。awaseと逆の選択です。両者を四点だけ並べます。
- 類似した症状: どちらも、合成されたイベントと物理由来のイベントが区別できなくなっていた
- 所有していた入力境界: awaseのImmCrossは対象アプリのIME状態を直接書き込める位置にいたが、
Gaiaは任意のWebアプリの上で動く汎用層で、個々のアプリの入力処理は所有していなかった
- 選んだ対策: awaseはKANJIキーをアプリへ渡さないという遮断、Gaiaは
IsSynthesizedByTIPという
フラグでの判別強化
- なぜ逆になったか: 渡す・渡さないを選べる立場にいたか、判別するしかない立場にいたかの違い
この違いは、どちらの設計が優れているかという話ではありません。ImmCrossプロファイルは、
set_ime_open_cross_processによって対象アプリのIME状態をすでに直接所有していました。だから
物理キーをそのアプリに渡さないという選択が可能でした。一方Gaiaは、任意のWebアプリの上で
動く汎用のIME層であり、個々のアプリの入力処理を所有してはいません。渡さないという選択肢が、
そもそも手元になかったのです。フィルタが正しいか、チャネル遮断が正しいかは、どの層が
物理入力を所有できるかによって決まります。所有できないなら、フィルタしか残っていません。
「排除は緩和に勝る」という原則は、この場面で初めて発見されたものではありません。小さな
子どものいる家庭を思い浮かべてください。「ストーブに近づいたら危ないよ」と繰り返し言い
聞かせる方法(注意で防ぐ対策)よりも、ストーブの周りに柵を置いて物理的に近づけなくする
方法(危険の原因そのものを取り除く対策)のほうが、確実に事故を防げます。労働安全の分野には、
この考え方を段階として整理した枠組みがあります(アメリカの労働安全衛生研究所NIOSHが示す
Hierarchy of Hazard Controlsが知られています)。危険源そのものを取り除く「排除」を、
注意書きや保護具よりも上位に置く考え方です。
ソフトウェア設計の分野にも、似た発想があります。日付を選ぶ画面で、過去の日付を選んで
しまってから「その日付は無効です」と後からエラーを出すのではなく、そもそも過去の日付を
カレンダーの選択肢に表示しない、という作り方です。「あり得ない状態はそもそも表現できない
ようにする」という言い回し(make illegal states unrepresentable、OCamlコミュニティで
広まった、ソフトウェア技術者Yaron Minskyの言葉とされます)は、この発想を型設計の言葉で
語ったものです。ただしこちらは値の表現方法についての原則であり、awaseが直面したような、
入力チャネルそのものをどちらが所有するかという問題とは、厳密には別の話です。
awaseの選択が独自だったのは、原則そのものではなく、「渡すか渡さないかを選べる立場に、
たまたま自分がいた」という条件を見極めた点にあります。同じ原則を知っていても、Gaiaのように
渡さないという選択肢そのものが手元にない場合は、フィルタを磨き続けるしかありません。
この境界にも、期限がありました
08b8661と0e364eaでの転換は、ImmCrossプロファイルが物理KANJIキーを所有できるという前提の
上に立っていました。しかしこの前提そのものが、後に揺らぐことになります。7月2日以降、
ImmCrossProbe自体の信頼性、具体的にはQtアプリが子ウィジェットごとに別々のIMM32コンテキストを
持つという問題が見つかりました。所有していたはずの境界が、実は思っていたほど単純ではなかった
ということです。この判定は、のちにFocusEpoch/derive_open()というモデルへ統合されることに
なります。渡す・渡さないを一度決めれば終わりではなく、その決定自体がどれだけ新鮮かを、
別の仕組みで確認し続ける必要があったのです。
「このアプリはKANJIを渡さなくてよい」という判定そのものも、突き詰めれば一つの観測でした。
一晩かけて構造的な答えを見つけたつもりでも、その答えが今も成立しているかどうかは、
また別に確かめる必要があったのです。フィルタから経路の所有へ問いを変えたことは、この夜の
答えとしては正しいものでした。しかし所有しているという判断自体を疑う必要が出てくるのは、
もう少し先のことです。
排除は緩和に勝る
合成入力と物理入力が構造的に区別できないとき、判定条件を増やすフィルタは行き詰まります。
この原則が働くのは、入力の発生源そのものを自分の層が所有しており、渡す・渡さないを
選べる場合に限られます。渡す前に握りつぶせる位置に、自分のコードが立っているかどうかが
分かれ目です。
実装の形としては、個々のケースを判定する条件式を増やすのではなく、対象を丸ごと
Consumeする一本の分岐に置き換えます。条件を増やすたびにコードは複雑になりますが、
経路を断つ実装はむしろ単純になります。
ただし、この原則は自分がその入力経路を所有していない場合には適用できません。所有権が
自分の層になければ、区別できないものを区別しようとするフィルタだけが残された手段になります。
また、所有しているという前提自体も、時間が経てば崩れることがあります。
第9章:増殖したFSMを解体する

同じ「温まっているかどうか」を、四つのファイルがばらばらに覚えていた
GJI(Google Japanese Input、Google 日本語入力)が入力を受け付けられる状態(warm)か、まだ準備中の状態(cold)かは、awaseの出力層にとって特に重要な判定でした。ところがこの判定は、最初から一つの場所にまとまっていたわけではありません。2026年6月時点で、WarmthContext.warmというbool値、CompositionStateというenumの値、gji_long_idleというbool値、gji_last_io_msという時刻の記録という、四つの別々のフィールドが、それぞれ別のファイルに散らばって存在していました。
この散らばりには理由がありました。GJIが本当にwarmになったかどうかは、一度の確認だけでは決まりません。フォーカスが移った瞬間の様子、その少し後の様子、さらに時間を置いた後の様子――同じ対象について、時間を置いて何度も観測を重ね、そのたびに得られる新しい情報を足していく必要があったのです。1回ごとの観測の呼び出しを「tick」と呼ぶなら、この判定は1tickでは終わらない、複数のtickにまたがる判定でした。単純なbool値は「いまこの瞬間はどちらか」を一つ答えるのには向いていますが、「複数回の観測を経て、いまどの段階まで進んだか」を覚えておくのには向いていません。新しい観測が来るたびに、そのつど別々の場所を書き換える必要があったため、更新が一部だけで止まってしまう不整合が起きやすくなっていました。
この不整合は、実際に繰り返しバグとして現れました。2026年6月時点で、warm/cold判定に起因するバグは少なくとも7件記録されています。中には、WarmthContext.warmのように、値が書き込まれるだけで、どこからも参照されていなかったフィールドさえありました。書いた本人でさえ、後から読み返さなければ、その値が実際には使われていないことに気づけなかったのです。
idle時間の分類も、同じ形の限界を抱えていました。当初はgji_long_idleというbool一つだけで、「長時間放置された後かどうか」を表現していました。ところが後になって、7〜10秒の「中程度のidle」と、10秒を超える「長いidle」を区別する必要が生じました。boolを1個増やすだけでは足りず、それを参照するすべての箇所を洗い出して書き換える必要があり、変更の範囲が広がっていきました。
2026年6月20日から22日にかけて、この四つの断片を一つの状態機械(FSM、finite state machine)に統合しました(GjiFsm、ADR-046)。状態機械とは、プログラムが取りうる状態をあらかじめ書き出し、次に何が起きたらどの状態へ移るかを表にしておく設計です。信号機が分かりやすい例で、赤・黄・青という決まった状態があり、時間が来ると赤→青→黄→赤の順に必ず切り替わります。GjiFsmは、OffCold(未活性)・OnCold(活性化待ち)・OnWarm(準備完了)・OnComposing(変換中)という状態と、その間の遷移をあらかじめすべて書き出す形にしました。idle時間の分類もColdKind(Short/Medium/Long)という一つの型にまとめ、待機時間などの値をそこから導出するようにしました。四つに散らばっていたbool・enumを一つの状態機械に集約したことで、「一部だけ更新される」という種類の不整合は、構造的に起こらなくなりました。
移行は一度に切り替えたわけではありません。まず新しいGjiFsmを追加し、旧来の判定結果と新しい判定結果が一致するかを、実行のたびに突き合わせて確認する期間を置きました。両者が一致し続けることを確かめてから、はじめて新しい方を正式な判定基準に切り替えています。動いているものをいきなり置き換えず、しばらく両方を並走させてから切り替えるという慎重さは、状態管理そのものを作り直すという、後戻りの利きにくい変更だったことの表れです。
こうしてGJIという一つの観測対象については、状態機械による一元管理が実現しました。しかしawaseが観測しなければならない対象は、GJIだけではありませんでした。Chrome向けの確認処理、TSFの初期化待ち、リテラル化の検出――どれも同じように、複数回のtickにまたがる状態を必要としていました。2026年6月22日から23日にかけて、この共通の形をTickableFsmという一つの取り決め(トレイト、複数の型に共通の振る舞いを約束する仕組み)として抜き出しました(ADR-047)。「時刻を受け取って内部状態を進める」「イベントを受け取って内部状態を進める」という二つの操作さえ用意すれば、どんな観測対象でも同じ枠組みで、tick駆動の状態機械として扱えるようになったのです。
この抽象化は、狙いどおりに機能しました。GJI用のGjiWarmupFsm、Chrome用のChromeProbe、リテラル検出用のLiteralDetectFsmなど、観測対象ごとに専用の状態機械が、次々と同じ枠組みの上に追加されていきました。ばらばらのbool・enumを一つにまとめるという最初の判断は、正しい判断でした。ところがこの成功が、少し違う形の問題を生み出すことになります。増えたのはFSMの型の数だけではありませんでした。フェーズをまたいで持ち越す値も、enumのバリアントも、FSMが増えるたびに一緒に増えていったのです。
「-1055行」は一つのコミットの数字だった
2026年06月27日07時29分35秒、f01d401というコミットが7個のファイルにまたがる差分を残しました。差分の内訳は「25 insertions, 1055 deletions」です。単日のdiffstatとしては、awaseの開発の中でも際立って大きな数字でした。
ただし、この-1055行という数字を「大規模な整理」の証拠としてそのまま読むのは早計です。削除された行の大半、969行はtsf/gji_warmup_fsm.rsというファイル1本の全削除によるものでした。つまりこの数字は、あちこちを少しずつ削った結果ではなく、一つの型を丸ごと消したことで生まれています。減った行数そのものよりも、何が起きて1本のファイルが丸ごと不要になったのかのほうが、この章の主題です。先に答えを言うと、これは単なる整理ではなく、状態機械という表現方法をさらに選び直した結果でした。
前節で見たTickableFsmは、増殖そのものを防ぐ仕組みではありませんでした。散らばったbool・enumを一元管理するという問題は解いていましたが、観測対象が増えるたびにFSMの型も増えるという設計だったからです。この章が扱うのは、IMEをコールドスタートさせる手続きという、awaseの中でもとりわけフェーズの多い処理です。フェーズが多いということは、それだけ「次に何をすべきか」を管理する状態表現が複雑になりやすいということでもあります。だからこそ、この日の変更は単発の掃除では終わらず、状態機械とコルーチンという2つの表現方法を並べて比較する材料になりました。
コルーチンとは、関数の実行を途中で一時停止し、あとで続きから再開できる仕組みです。普通の関数は、呼ばれたら最後まで一気に実行されます。コルーチンは違い、途中まで進んだら制御をいったん呼び出し元へ返し、次に呼ばれたときにその続きから動くという書き方ができます。しおりを挟んで本を読むことに近く、最後まで一気に読まなくても、しおりの場所から続きを読めます。この章に登場するStepCoroも、この意味でのコルーチンとして実装された、awase独自の仕組みです。
執筆準備の初期には、この変更を「三つのFSMの統合」と整理していました。コミットログとADR-053を突き合わせて確認し直すと、経緯は違っていました。正確には五種類のFSM型を棚卸しし、手続き的な処理を三本のStepCoroへ移す一方で、LiteralDetectFsmは独立したFSMとして残していたのです。三つではなく五つを対象にした整理であり、しかも五つすべてがコルーチンに置き換わったわけではありません。この訂正を踏まえると、この章の問いは「FSMをコルーチンに置き換える話」ではなく、次のように立てるべきものになります。状態機械とコルーチンを、何を基準に使い分けるべきか。
五つのFSMが、SwitchMachineで機械を乗り換えていた
awaseのTSF(Text Services Framework)には、IMEをコールドスタートさせるための一連の手続きがありました。GJI(Google Japanese Input、Google 日本語入力)状態のprobe確認・FreshF2キーの送信・名前変更の待機・変換文字列の送信・リテラル(未変換文字列)の検出という、順番の決まった5フェーズです。
2026年06月27日時点で、この手続きは次の5個の独立したFSM型として実装されていました。
| FSM名 | ファイル | 行数 | 生成コミット |
|---|
GjiWarmupFsm | tsf/gji_warmup_fsm.rs | 969行 | fcd1b82(06-22) |
LiteralDetectFsm | tsf/literal_detect_fsm.rs | 132行 | 8608062(06-23) |
SacrificialWarmupFsm | tsf/sacr_warmup_fsm.rs | 299行 | 06-14〜06-24の累積 |
ChromeGjiReinitFsm | tsf/chrome_gji_reinit_fsm.rs | 95行 | 2c8d647(06-24) |
TsfProbeMachine | tsf/probe_fsm.rs | 827行 | 05月来の累積 |
生成コミットの日付を見ると分かるように、これらは一度に設計されたものではありません。SacrificialWarmupFsmは06-14から06-24までの間に何度も手が入り、その途中でChrome固有の再初期化問題が見つかったことでChromeGjiReinitFsmという別の型が切り出されました。TsfProbeMachineにいたっては05月から改修が積み重なり、06-27の時点で827行にまで育っていました。個々のFSMは、それぞれが生まれた時点で発生していた個々の問題に対する、その時点では妥当な回答でした。増えすぎたのは、後から振り返って初めて分かることです。
これらのFSMは互いに独立してはいましたが、一つのフェーズが終わると次のFSMに切り替えるSwitchMachineというアクションで連鎖していました。GjiWarmupFsmからLiteralDetectFsmへ、SacrificialWarmupFsmからChromeGjiReinitFsmへ、というようにです。呼び出す側から見れば、複数のフェーズが一つの手続きとして流れているように見えても、実装の内部では別々の型を渡り歩く構造になっていました。
この連鎖の構造には、増えるたびに重くなる代償がありました。フェーズを一つ追加するたびに、enumのバリアントとmatchのアームが線形に増えます。さらに厄介だったのは、フェーズをまたいで持ち越す情報の扱いです。ローマ字文字列やバックスペースの回数は、その手続きの間だけ意味を持つ値です。これを次のFSMに引き継ぐには、structのフィールドとして保持するか、SwitchMachineのペイロードとして手渡すしかありませんでした。一時的にしか使わない値のために、恒久的なフィールドを用意し続ける構造になっていたのです。
このフィールドは、フェーズが進むたびに「もう使わないが、まだstructには残っている」という状態を経由します。読む側は、あるフィールドがいまのフェーズで意味を持つのか、前のフェーズの残骸なのかを、コード全体を追わないと判断できません。フェーズの数だけmatchアームが増える負担と、フィールドの寿命が読み取りにくくなる負担は、別々の問題ではなく、同じ設計判断から生じた一対の症状でした。
一つの関数に直線で書いたら、969行が消えた
06月27日07時22分02秒、e548e63でtsf/step_coro.rsという新しい基盤が追加されました。StepCoroと呼ばれるこの仕組みは、unsafe(安全性チェックを一部省く書き方)も使わず、nightly(正式リリース前の実験的機能)にも外部クレートにも依存せず、標準ライブラリだけで動く軽量なコルーチンでした。
StepCoroの考え方はこうです。フェーズをまたぐ一時的な値は、structのフィールドに昇格させる必要はありません。1本のasync関数の中に、順番通りに書けばよいのです。関数の途中でyield_stepを呼ぶと、その時点までの出力を返しつつ、次のtickの入力を受け取って続きから再開します。
async fn phase_body(ch: Channel) {
loop { // フェーズ1: probeループ
let input = yield_step(&ch, vec![]).await;
if probe.check_outcome(total_max_ms).is_some() { break; }
}
yield_step(&ch, vec![SendFreshF2]).await; // フェーズ2
loop { /* フェーズ3: NameChangeWait、同じ関数内で続けて書ける */ }
}
このコルーチンを、既存のtick駆動の呼び出し側から見て以前とまったく同じ形に見せるには、もう一段の橋渡しが要ります。TickableFsmというトレイトへの適合を、薄いラッパーstructが引き受けました。
impl TickableFsm for GjiWarmupCoro {
fn tick(&mut self, env: &TsfEnvSnapshot) -> Vec<ProbeAction> {
let input = TickInput { env: *env, .. };
match self.coro.step(input) {
CoroStep::Yielded(actions) => actions,
CoroStep::Complete => vec![ProbeAction::Done],
}
}
}
呼び出し側は相変わらずtickを呼ぶだけで、内部がFSMかコルーチンかを意識する必要がありません。変わったのは実装の内側だけで、外側の契約は保たれています。
StepCoro本体は、unsafeもOSへの呼び出しも持たない、入力を受け取って次の出力を返すだけの型です。フェーズがいくつあっても、実際にGJIやTSFを動かさなくても、TickInputを順番に与えてCoroStepの列を確認するだけで、フェーズ間の遷移をテストコードの中だけで再現できます。ADR-053には、この構造によってChromeProbeやLiteralDetectFsmが独立してユニットテスト可能になったと記録されています。5個のファイルをまたいでいた手続きが1本の関数にまとまったことは、読みやすさだけでなく、実機を用意せずに検証できる範囲を広げたことにもつながっています。
10分後の07時22分12秒、ccd4711が最初の適用例を追加しました。GjiWarmupFsmとLiteralDetectFsmのチェーンを、GjiWarmupCoroという1本のコルーチンにまとめたのです。このコミットのメッセージには「inline LiteralDetect」という語があります。apply_transmit_doneがfalseを返すContinueパスによって、StartLiteralDetect → SwitchMachine → LiteralDetectFsmという機械切り替えが不要になった、という意味です。リテラル検出の処理は、独立したFSMを呼び出すのではなく、コルーチン本体の中にそのままインライン記述されました。同じメッセージには「GjiWarmupFsmは削除せず残置(Chromeパスのコメント参照元として)」とも書かれており、この時点ではまだ古いFSMを消す踏ん切りがついていません。
7分後の07時29分35秒、f01d401がGjiWarmupFsm本体を削除し、ProbeAction::StartLiteralDetectバリアントも合わせて撤去しました。これが冒頭の-1055行です。コミットメッセージには「GjiWarmupCoroへの移行完了に伴い不要になったコードを削除する」とあります。フェーズ間の情報を局所変数として持ち運べるようになった結果、恒久的なフィールドとenumバリアントの大半が不要になり、削除できる状態になっていました。さらに5分ほど後のb077c3dでは、この置き換えに伴って残っていた警告を修正する小さな後始末も行われています。
この書き換えで変わったのは行数だけではありません。以前は、GJI probeからLiteralDetectまでの手続きを追うには、5個のファイルをまたいでmatchアームとenumバリアントを行き来する必要がありました。書き換え後は、同じ手続きが1本の関数の中で、上から下へ読める順序で並んでいます。フェーズの数が増えても、増えるのは関数の行数であって、ファイルをまたぐmatchアームの数ではありません。
ここまでの経過だけを見ると、答えは単純に思えます。FSMの連鎖をコルーチンに書き直せば、行数は減り、フェーズ間の受け渡しも楽になる。5つのFSMをすべて同じ手順でコルーチン化すればよい、という結論に飛びつきたくなります。
消えなかった一つ
ところが、LiteralDetectFsmというファイルは、f01d401のあとも削除されませんでした。crates/awase-windows/src/tsf/literal_detect_fsm.rsは現在も333行のFSMとして存在し続けています。
削除されなかったLiteralDetectFsmは、06月27日の変更で対象から外れた古い実装ではありません。コールドパスでの役割はコルーチンにインライン化されて消えましたが、ウォームパス(変換後にIME側の確定表示を待つ経路)では、同じ名前の型が独立したFSMとして今も使われています。tsf/tickable_fsm.rsには「LiteralDetectFsm | warmパスのpost-transmit composition確認 | tick, cold_seq_hintのみ」という注記が残っています。
同じ「リテラル検出」という処理でありながら、コールドパスでは関数の中の一節としてインライン化され、ウォームパスでは独立したFSMのまま残された。ここに、前節で立てた「全部コルーチン化すればよい」という単純な結論では説明できない境界線があります。行数を減らすこと自体を目的にしていたなら、この333行も削除の対象になっていたはずです。そうならなかったのは、削除できるかどうかとは別の判断基準が働いていたからです。
整理の前後を図にすると、五つのうち一つだけが独立したFSMのまま残ったことが分かります。

仮に、この基準を意識しないままウォームパスのLiteralDetectFsmもコルーチンに書き直していたらどうなったでしょうか。IME側の確定表示は、いつ届くか呼び出し側には分かりません。コルーチンの内部で待ち続けさせるだけでは、外部から「いまどの段階か」を問い合わせる手段が失われます。結局、その問い合わせを可能にする状態を、コルーチンの外側に別途持たせる必要が生じたはずです。複雑さを消したのではなく、別の場所へ移しただけになっていたことになります。
残す基準は、順番と生存期間だった
コールドパスのLiteralDetectFsmとウォームパスのLiteralDetectFsmは、同じ処理内容でありながら扱いが違いました。両者を分けたのは、処理の内容ではなく、処理の形そのものでした。
コールドパスでのリテラル検出は、GJI probeからFreshF2送信、NameChangeWaitを経て到達する、決まった順序の最後のフェーズです。開始から終了までの道筋があらかじめ決まっており、一度だけ実行されれば役目を終えます。これはStepCoroが得意とする形でした。
ウォームパスでのリテラル検出は違います。IME側がいつ確定表示を返すかは呼び出し側には分かりません。外部からのtickを待ち続け、条件を満たすまで生存し続けます。さらに、tickable_fsm.rsの登録が示すように、この状態は呼び出し側からtickを通じて問い合わせられる対象でもあります。状態そのものが、他のコードにとって意味を持つドメイン上の値になっていたのです。
同じ関数名・同じ処理内容であっても、「いつ終わるか」と「誰がその途中経過を必要とするか」が違えば、向いている表現方法も違ってきます。コールドパスのリテラル検出は、完了したかどうかさえ分かればよく、途中経過を他のコードが参照することはありません。ウォームパスのリテラル検出は、完了を待つ間ずっと、いまどの段階にあるかが外部から意味を持ちます。この2つの違いを一般化すると、次のような基準になります。
| 特徴 | StepCoroに向く | FSMとして残すべき |
|---|
| 開始から終了までの順序 | あらかじめ決まっている | 外部イベントに応じて分岐する |
| 生存期間 | 一回限りの手続きで完結する | 長期間、外部からの入力を待ち続ける |
| キャンセル・タイムアウト | 手続きの一部として組み込める | 状態自体がタイムアウトの基準になることがある |
| 状態の意味 | 呼び出し側には見えない内部の実装詳細 | 呼び出し側が問い合わせて判断材料にする |
ウォームパスのLiteralDetectFsmは、右列の条件をすべて満たしていました。だから残りました。左列の条件を満たす手続きだけが、コルーチンへの書き換えの対象になったのです。
残り二系統も、同じ基準で割り切れた
この基準が単なる後付けの説明ではないことは、同じ日に行われたもう2つの統合を見ると分かります。
08時09分55秒の2c756ddは、SacrificialWarmupFsm(299行)とChromeGjiReinitFsm(95行)をSacrificialWarmupCoro(269行)にまとめました。コミットメッセージには「StartChromeGjiReinit(SwitchMachine) → SendChromeGjiReinit(Continue)に変更しFSM切り替えを撤去。コルーチン本体がそのままIME確認待機フェーズへ続く」とあります。同じメッセージには「deferred_vks / notify_start_compositionをTickInput経由で収集」ともあり、以前は別々のFSMがそれぞれ抱えていた保留中の値も、TickInputという一つの入力経路にまとめられています。Chrome再初期化前のウォームアップから再初期化完了までは、順序が決まった一回限りの手続きであり、基準の左列にそのまま当てはまりました。
17時03分17秒のd1d6d17は、TsfProbeMachine(827行)をTsfProbeCoroに置き換えました。ProbePhaseenum・WaitingFor・SendState・SystemClockといった内部型はすべて撤去されました。ChromeProbeからSacrificialWarmupまたはTransmitを経てLiteralDetectに至る3フェーズは、tsf_probe_coro_bodyという1本の関数に統合されています。この変更では237行が削減されました。
さらに同じ日の17時49分頃、9d08b66はStepCoroをこのプロジェクト専用の実装からtimed_fsm::coroという公開クレートへ昇格させ、直後の118b3cfはローカルのstep_coro.rsを撤去して、この共有クレートの利用に一本化しています。07時22分に一つのファイルとして生まれた仕組みが、同じ日の夕方には他の箇所からも再利用できる基盤に育っていたことになります。
07時22分のGjiWarmupCoroから17時03分のTsfProbeCoroまで、3系統の書き換えは同じ一日のうちに終わっています。これは急いで済ませたということではなく、最初の書き換えで基準がはっきりしたあとは、残り二系統への適用に迷いが要らなかったことの表れです。判断に時間がかかったのは、基準を見つけるまでの最初の一系統だけでした。
3件の変更と1件の非変更をまとめると、次の対応関係になります。
| 変更前 | 変更後 |
|---|
GjiWarmupFsm(969行) | GjiWarmupCoroへ統合、ファイルごと削除 |
LiteralDetectFsm(コールドパスでの利用) | GjiWarmupCoro内にインライン記述、型としての呼び出しは消滅 |
SacrificialWarmupFsm(299行) + ChromeGjiReinitFsm(95行) | SacrificialWarmupCoro(269行)へ統合 |
TsfProbeMachine(827行) | TsfProbeCoroへ統合、237行削減 |
LiteralDetectFsm(ウォームパスでの利用) | 変更なし。唯一残ったFSM |
5個あったFSM型のうち、順序が決まった一回限りの手続きを担っていた4個(実体としては3系統)はコルーチンへ移り、外部イベントを待ち続け状態そのものが問い合わせの対象になっていた1個だけがFSMのまま残りました。基準は3回とも同じでした。
適用限界
この基準は、StepCoroがFSMより優れているという主張ではありません。awaseのStepCoroはunsafeもnightly機能も使わず、標準ライブラリの範囲で動く軽量な実装であり、外部のasyncランタイムやジェネレータクレートの代わりに選ばれたものでした。ADR-053には、tokioのような外部ランタイムは単一スレッドのタイマー駆動モデルには過剰であり、generatorクレートやnightlyのコルーチン機能はnightly依存または外部クレート依存を新たに抱え込むことになる、という判断が記録されています。個人開発で長期にわたって保守する前提を考えれば、依存を増やさない選択にも理由がありました。
この基準が答えているのは、「手続きの形をした処理」と「状態そのものに意味がある処理」のどちらに当てはまるか、という一点だけです。ある処理が両方の性質を併せ持つ場合や、一回限りの手続きとして始まったものが後から外部に問い合わせられる対象へと変わっていく場合を、この基準だけでどう扱うかは、この章の範囲では答えが出ていません。境界線を引く基準は手に入りましたが、境界線そのものが動く場合の扱いは、また別の問題として残っています。
この基準そのものは、TSFのコールドスタート手続きに固有のものではありません。一回限りの手続きか、長期間生存して外部から問い合わせられる状態かを見分けるという判断は、awaseの他の箇所でも、フェーズを持つ処理を実装するたびに繰り返し現れる問いです。ただし、この章で確認できたのはあくまで一つの手続き群における3+1の内訳だけであり、他の箇所でも同じ比率になるとは限りません。
順番と生存期間を基準に、手続きと状態を分けられるようになりました。しかし、その手続きや状態が、そもそも何の遷移を表しているのかという問いには、まだ答えていませんでした。
他分野への転用
一直線の手続きと、問い合わせられる状態を分ける発想は、他の場所にもある
StepCoroが「フェーズをまたぐ一時的な値を、structのフィールドではなく関数の中の変数として書く」ためにしていたことは、実は多くのプログラミング言語がすでに、コンパイラの機能として提供しています。JavaScriptやPython、そしてRust自身が持つasync/awaitという構文は、見た目こそ普通の関数のように上から下へ書けますが、内部ではコンパイラが待機のたびに中断・再開できる状態機械へ自動的に変換しています。プログラマが手でenumのバリアントを書き並べる代わりに、コンパイラがその変換を肩代わりしているという違いはありますが、「一直線の手続きを、途中で止めて後で続きから再開できる形に変換する」という発想そのものは同じです。awaseのStepCoroは、この変換を外部クレートやnightly機能に頼らず、自前で小さく実装したものだと言えます。
一方、「長期間生存し、外部から問い合わせられる状態」の側にも、身近な対応物があります。フロントエンド開発でよく使われるRedux(状態管理ライブラリ)は、ボタンを押した瞬間だけ実行される一回限りの処理と、アプリ全体が常に持ち続け、どの画面からでも問い合わせられる状態とを、意図的に別の仕組みとして扱います。前者は関数呼び出しで済み、後者だけがstoreと呼ばれる専用の置き場所を持ちます。ウォームパスのLiteralDetectFsmが独立したFSMとして残ったのは、この「常に問い合わせられる置き場所」を必要としていたからであり、Reduxのstoreが一回限りの処理を素通りさせるのと、選んでいる基準は同じです。
この対応関係は、awaseがこの区別を発明したことを意味しません。一回限りの手続きと、長期間問い合わせられる状態という2つの性質は、プログラミング言語の機能としても、ライブラリの設計としても、繰り返し同じ形で発見されてきました。3回とも同じ基準で割り切れたという本章の経過は、この繰り返しの一例にすぎません。
設計原則
フェーズ間の一時的な値は、structフィールドではなく局所変数で表現できないか、まず検討する。
この原則が適用できるのは、処理の開始から終了までの順序があらかじめ決まっており、一回実行されれば役目を終える手続きに対してです。フェーズが増えるたびにenumバリアントとmatchアームが線形に増えていく設計は、この条件に当てはまる兆候です。
実装の形としては、1本のasync関数の中に手続きを直線的に記述し、フェーズの区切りごとにyield_stepで外部との入出力を受け渡します。フェーズ間で持ち越す値は、恒久的なフィールドではなく、関数スコープに閉じた局所変数として自然に表現できます。呼び出し側から見た契約は、薄いラッパーを介して以前と変えずに保つことができます。
ただし、外部イベントに応じて長期間分岐し続ける処理や、状態そのものを呼び出し側が問い合わせて判断材料にする処理には、この原則は当てはまりません。そのような処理は、局所変数に閉じ込めずに、FSMとして状態を外部から見える形のまま残すべきです。
第10章:意図・観測・遷移・障壁

第I部からここまで、私たちは常に何かを直してきました。最初の一文字の化けを直し、わからない
状態に型を与え、フィルタでは防げない偽イベントを塞ぎ、増殖したFSM群を解体しました。それぞれの
修正は、目の前の症状を確かに減らしました。
しかし、直すたびに一つの違和感が残りました。修正のたびに、beliefやstateと名付けられた
一つの構造体へ、新しい分岐が増えていきました。同じ場所を直しているはずなのに、次の修正もまた
同じ場所へ集まってきます。この繰り返し自体が、個々の症状ではなく、もっと手前にある何かを
疑うべきだという合図でした。
第IV部は、個々の修正から離れ、これまで何を一つの状態として扱ってきたのかを問い直します。
観測した状態と、いま実行してよい状態は、同じではありませんでした。この本のここまでの章は、
その二つを区別しないまま、症状ごとに局所的な対策を積み重ねてきたとも言えます。
第10章では、ime_onという一つの値が、実際には四つの異なる問いに答えていたことを示します。
第11章では、その四つのうち一つ、観測の鮮度をどう構造的に守るかという問いへ、3か月をかけて
たどり着いた答えを見ます。
ime_onという一本の値が、四つの役割を兼ねていた
開発2日目、2026年3月29日のコミットには、すでにime_onという値が登場します。
55b33c9 2026-03-29 Add IME/thumb key instant promotion + hybrid buffering strategy
5b0b42a 2026-03-29 Auto IME OFF for non-browser Undetermined controls (game/gvim protection)
この二つのコミットメッセージ自体が、すでに一筋縄ではいかない事情を示唆しています。
「hybrid buffering」も「Undetermined controls」も、単純なオン・オフ以上の状況を、
この一つの値の周辺で捌こうとしていたことの現れです。
ime_onは、その名の通り「いまIMEはオンか」を表すための、単純なbool値として出発しました。
しかし、実際にコード上で読み書きされる場面を数えると、この一つの値は少なくとも四つの異なる
問いに答えるために使われていました。
第一に、ユーザーが親指キーを押して「日本語入力を始めたい」と意思表示したかどうか。これは
ユーザー自身の頭の中にしかない情報で、awaseはキー入力という間接的な形でしか受け取れません。
第二に、フォーカス中のアプリケーションやWindows側が「いまIMEはオンだ」とどう報告してきたか。
これは複数の経路から、しかも互いに矛盾しうる形で届きます。
第三に、その報告を受けてawase側がIME切り替えのOS呼び出しを送り、まだ完了通知を待っている
途中かどうか。送った直後は、成功したのか失敗したのか、awase自身にもまだわかりません。
第四に、いまこの瞬間にキー入力をIME側へ回してよいか、それとも直接入力へ回すべきかという、
後戻りできない選択をしてよいかどうか。一度送出したキー入力は、取り消せません。
一つのboolに、意思・報告・進行中の変更・許可という、四つの異なる性質の情報を同居させていた
ということです。それぞれは更新される頻度も、信頼できる度合いも、失効するタイミングも違います。
しかし読み書きする側からは、同じ一つのフィールドにしか見えていませんでした。
この混同は、抽象的な設計論としてではなく、具体的な分岐の増殖として姿を現します。ある呼び出しは
「ユーザーの意思」を読みたいだけなのに、実際に手に取れるのは四つが混ざったime_onしかありません。
そこで呼び出し側は「いまはこの分岐を通っているはずだから、この値は意思のはずだ」という前提を
コードの外に置いたまま、値をそのまま使いました。前提が正しい間は動きます。前提が崩れる場面――
たとえば報告がまだ届いていないうちに次の意思表示が来た場合――にだけ、症状が現れました。
たとえば、古い観測がまだ有効だと誤って採用されれば、ユーザーが望んだ状態と、実際にエンジンが
動作するモードが食い違います。ユーザーからは、何も操作していないのに入力方式が急に変わった
ように見えます。原因を追うと、その値が「意思」なのか「報告」なのかが、コードのどこにも
書かれていないことに行き着きます。
四つの問いには、四つの層で答え直す必要があった
ADR-032(docs/adr/032-ime-state-reducer-4-layer-model.md)は、この混在を次のように
整理しています。
- 責務の混在――「ユーザーが望む状態」「OSが報告した状態」「awaseが適用中の状態」
「一時的な例外」が同じ
belief.ime_onフィールドに優先度順で混ざっており、reducer内で
各場面ごとに分岐が増え続けた
- observationがintentを破壊する――
observer_pollは通常set_open_requestで抑制されるが、
focus_probe後にset_open_requestが「消費済み」状態になると、staleなobserveがbeliefを
上書きし、Engineが誤った認識で動作する
一つ目は、四つの役割が同じ場所に置かれていたという指摘です。二つ目は、その同居によって実際に
何が起きるかという指摘です。ここで言うreducerとは、いまの状態と新しく届いたイベントを受け取り、
次の状態を一箇所で決め直す処理のことです。この章を通じて、四層の情報を最終的に一つの判断へ
束ねる役目を担うのが、このreducerになります。優先度で並べただけの値は、優先度の低いはずの
観測が、高いはずの意図を上書きしてしまう場面を防げませんでした。
reducerを一箇所に集約したことには、もう一つの効果があります。reducerは「いまの状態」と
「届いたイベント」を受け取り「次の状態」を返すだけの関数であり、実際のIME・GJI・Windowsを
動かさなくても、四層の値を手で組み立てて渡すだけでテストできます。これから見る5回の決着は、
いずれも実機で使っているうちに発覚したバグでした。reducerが一箇所にまとまった後は、同じ
壊れ方をテストコードとして固定し、実機なしで再発を検知できるようになっています。
この整理から導かれたのが、意図・観測・遷移・障壁という四つの層です。四つの層は、単に名前を
分けただけではありません。層ごとに、答える問いは異なります。誰が書き込めるか、どの程度確実か、
いつ失効するかも、層ごとに異なります。
| 層 | 答える問い | 誰が書くか | いつ失効するか |
|---|
| Intent(意図) | 何を実現したいか | ユーザー操作・awase自身の判断のみ | 次の意思表示があるまで有効 |
| Observation(観測) | 外部世界について何が観測されたか | 複数のprobe・pollが並行して書く | 観測した瞬間から古びていく |
| Transition(遷移) | 変更処理はどこまで進んでいるか | OSへの適用要求を出した経路 | 完了通知かタイムアウトで終わる |
| Barrier(障壁) | いま不可逆な作用を許してよいか | 他の三層を読んだ上で最後に一度だけ | 判断の都度、次の入力までしか有効でない |
四層の関係を図にすると、ObservationとIntentがそれぞれ独立にTransitionとBarrierへ
つながり、最後にBarrierだけが作用の可否を決める形が見えます。

実際のコードでは、優先度によって並んでいた五段階のソースが、そのままこの四層に対応します。
sync_keyとphysical_key(優先度1〜2)はIntentへ、set_open_request(優先度3)は
Transitionへ、それぞれ再分類されました。focus_probeとobserver_poll(優先度4〜5)は
Observationへ、ctrl_bypass_holdのような一時隔離の仕組みはBarrierへ、同様に割り当てられて
います。優先度という一本の軸で並んでいたものが、四つの異なる軸へ分解されたということです。
Intentの列とObservationの列を並べて見ると、両者は書く主体からして違います。Intentを
書き込めるのは、ユーザーの操作かawase自身の判断だけです。ADR-032の原則は、これを一字一句
こう定めています。
UserIntentだけがdesired_openを即時に変えられる
Observationは外部からの報告で、書き込む主体は複数(focus_probe、observer_poll等)存在します。
しかし、Observationは決してIntentを直接書き換えてはいけません。同じ原則の続きがこれを定めます。
Observerはdesired_openを直接壊さない――observer/モジュールからdesired_openへの代入を
直接行わない。ImeEvent::ObserverReportedをdispatchしてreducerに判断させる
Observationは「観測した」という事実だけを運びます。それをIntentへ反映してよいかどうかの判断は、
必ずreducerという一箇所へ集約するということです。
Transitionは、OSへの適用要求を送ってから完了通知を受け取るまでの、進行中の状態を表します。
IntentともObservationとも異なり、時間とともに自然に終わる(成功するか、タイムアウトするか)
という性質を持ちます。Transitionが終わる前に新しいIntentが来た場合、どちらを優先するかは
Transition層だけでは決められません。この調停こそがreducerの役目であり、Transitionという
箱を単独で作っただけでは、調停の問題は解決しないということも、あわせて確認しておく必要が
あります。
Barrierは、四層の中でもっとも直接的に作用と結びついています。他の三層がどんな値であっても、
Barrierが「いまは許可しない」と判断すれば、実際のキー入力送出は止まります。逆に言えば、
Barrierは他の層の値を信頼した上で、最後に一度だけ判断する場所として設計されているということです。
四層はまた、互いの内部表現を共有しません。ObservationはどのprobeがどんなAPIで観測したかという
出所の情報を持ちますが、Intentはそれを知る必要がありません。Intentが必要とするのは「ユーザーが
何を望んでいるか」だけであり、観測がどこから来たかを混ぜて受け取れば、また責務は元へ戻ります。
四つの箱を分けるということは、値を分けるだけでなく、それぞれの箱が知ってよい情報の範囲を
分けるということでもあります。
四層のうち、ObservationとBarrierの関係が、この本を通じて繰り返し問うてきたことの核心です。
観測したことと、いま実行してよいことは、同じではありません。Observationがどれだけ正確でも、
Barrierが別に判断しない限り、正確な観測がそのまま作用の許可にはなりません。
他分野への転用
操縦桿とセンサーと保護則の関係に似ている
この四層構造は、awase固有の発明ではありません。航空機のフライバイワイヤ(操縦桿の動きを
機械的なケーブルではなく電気信号でコンピュータに伝え、コンピュータが実際の舵面を動かす
方式)は、よく似た四層をすでに持っています。パイロットが操縦桿を倒す動作はIntent(何を
実現したいか)にあたります。速度計・迎角センサー・姿勢センサーが返す値はObservation(外部
世界について何が観測されたか)にあたります。舵面が実際に動いている途中の状態はTransitionに
あたります。そして、パイロットの意図とセンサーの観測をどちらも読んだ上で、失速や過大な
荷重につながる操作を最後に拒否する飛行制御コンピュータの保護則(フライトエンベロープ保護)
は、まさにBarrierの役目です。パイロットがどれだけ強く操縦桿を引いても、保護則が「いまは
許可しない」と判断すれば、機体はその通りには動きません。
この類似は偶然ではありません。「何かを望む主体」と「外部の状態を報告する経路」と「実際に
不可逆な作用を許すかどうかの最終判断」を分けるという発想は、人間の意図を機械の動作へ翻訳
する場面であれば、対象がキーボードでも操縦桿でも同じ形になります。awaseとフライバイワイヤの
違いは、後者では判断を誤れば人命に関わるため、保護則という層が最初から明示的に設計される
点です。awaseがこの区別に至ったのは、observationがintentを壊すという具体的なバグを5回
繰り返した後でした。設計の必要性に気づく順序は違っても、行き着いた形は同じでした。
これまでの五つの決着を、同じ混同の解消として読み直す
ここまでの決着を、この四層に当てはめ直すと、それぞれが実は同じ種類の問題――四層のどれか一つを、
専用の仕組みなしに済ませようとしていた問題――だったことが見えてきます。四層のどれか一つを
直すことと、四層すべてを一度に用意することは、別の作業です。以下では、すでに読んだ五つの章を、
この観点から棚卸しします。
| 章 | 当時の決着 | 四層での位置づけ |
|---|
| 第4章 | 固定待機からLiteralDetectFsmへ | Transitionの進行度を、時間の長さで代用していた |
| 第5章 | センチネル値からUnknown/Optimistic/Confirmedへ | Observationの不確実性を、型として表現し直した |
| 第6章 | BeliefStoreを経てshadow_modelへ集約 | Observation・Intent・作用履歴を一つの構造へ集めすぎた |
| 第8章 | フィルタ強化から経路遮断へ | Barrierの役割を、後段のイベント判定で代用していた |
| 第9章 | 増殖したFSM群からStepCoroへ | Transitionの表現形式を選び直しただけだった |
第4章では、Windows TerminalとChromeという別々のアプリで、それぞれ最初の一文字が化けるという症状に
向き合いました。二つの事件は見た目こそ似ていましたが、発端の事件を直したあとも、待機時間を
600ミリ秒から1500ミリ秒、500ミリ秒へと調整し続ける展開が残りました。当時はこれを「正しい
待機時間を探す」問題だと考えていました。しかし四層に当てはめれば、これはTransition、つまり
変更処理がどこまで進んだかという問いに、時間の長さという代用品を当てていただけです。時間定数を
どれだけ調整しても、進行中かどうかという事実そのものを表現していない限り、別の環境ではまた
ずれます。最終的にLiteralDetectFsmへ移行したのは、Transitionを時間ではなく状態として表現し
直したということでした。
第5章では、センチネル値が「未観測」「古い」「失敗」を区別できないという問題に、Unknown・
Optimistic・Confirmedという型で答えました。これは四層のうちObservationに対する答えでした。
値だけでなく、その値をどのように知ったかを型に含めたという工夫は、Observation層専用の解決
だったと言えます。裏を返せば、この型がどれだけ精密になっても、IntentやTransition、Barrierの
問題には手が届きません。実際、この型が導入されたあとも、第6章・第8章の症状は解決していません。
一つの層をどれだけ丁寧に作り込んでも、別の層の混同は残るということを、章をまたいだ経過が
示しています。
第6章では、非同期の観測がbeliefを汚染するという問題に、名前を変えながら何度も向き合いました。
最終的にshadow_modelという単一のSSOTへ昇格させています。四層で振り返ると、この構造は
Observationと、Intentと作用の履歴を、一つの入れ物へ集めすぎていました。SSOTにするという判断
自体は誤りではありません。ただし、SSOTの中で四つの役割が再び混ざれば、症状は形を変えて戻って
きます。実際、7月4日にはALT+TAB操作でこの構造が再発しています。当時は「集約が足りなかった」
のか「集約しすぎた」のか、判然としないまま次の対策に進んでいました。四層という物差しを
先に持っていれば、shadow_modelという一つの入れ物の中に、まだ四つの役割が同居していると
気づけたはずです。
第8章では、LINEが送る偽イベントに対して、一晩のうちに段階的にフィルタを強化しました。フィルタを
一段強めるたびに、次の反例がまた見つかるという展開が続きます。最終的にたどり着いたのは、
フィルタを強くすることではなく、そもそも経路を塞ぐという判断でした。四層で見れば、フィルタが
担おうとしていたのは「いま作用を許可してよいか」というBarrierの問いです。イベントが本物か
どうかを後段で判定するやり方は、Barrierの問いに、Observationの精度を上げることで答えようと
していたとも言えます。経路を塞ぐという決着は、Barrierの判断をイベント判定の外側、経路そのものへ
置き直したということです。
第9章では、増殖したFSM群を棚卸しし、複数のFSMのうち大半をStepCoroへ移し替え、一部をFSMのまま
残しました。個々のFSMは名前も設計方針もばらばらでしたが、共通していたのは、いずれも
「変更処理がどこまで進んだか」というTransitionの問いに答えるための道具立てだった、という点です。
これはTransitionという同じ層の中で、表現形式を選び直した作業です。Intent、Observation、
Barrierの設計そのものには手を付けていません。
この読み直しからわかるのは、次の一点です。五つの章は、別々の問題を解いていたのではありません。
同じ四層構造のどこか一つを、専用の仕組みなしに済ませようとして、繰り返しつまずいていました。当時は
それぞれ独立した事件として経験しました。しかし振り返れば、事件の数だけ問題があったのではなく、
一つの構造の中の四つの穴を、順番に踏み抜いていただけだったということです。
名前を変えた同じ日に、また名前が足りなくなった
第6章のBeliefStoreは、この混同がもっとも集中した場所でした。ここでは経緯を再演せず、実際の
改名だけを一枚の表にまとめます。
| 日付 | 名前 |
|---|
| 〜2026-05-21 | ImeBeliefStore |
| 2026-05-22 | ImeApplyLatch |
| 2026-05-24 | LastAppliedImeState |
| 2026-05-29 | shadow_model(IME SSOTへ昇格) |
注目すべきは、5月22日の1日のうちに名前が2回変わっている点です。ImeBeliefStoreを
ImeApplyLatchへ改名した同じ日に、内部のshadow_ime_onという値を、Confirmed・Intended・
Unknownという型へ分割しています。名前を変えた直後に、その名前が指す構造がまた不足だと
わかったということです。
四層で見れば、この改名の繰り返しは偶然ではありません。名前を変えても、Observation・Intent・
作用履歴という異なる責務は分解されないままだったからです。責務が型として分かれない限り、
次の名前もまた同じ理由で不足になります。
同じパターンは、awaseの中で少なくとももう一度、独立に現れています。前置キーの待機キューでは、
複数のprobeがそれぞれ個別に保留中のキーを抱えており、probeを切り替えるたびに、保留していた
キーごと破棄されるという不具合がありました。「にゅうりょく」と打ったつもりが「にうりょく」に
なる、というように、打鍵そのものが物理的に消える症状です。解決は、単一の所有者へ保留状態を
集約することでした。一箇所に集約すれば直るはずだという期待が、集約点自体の中でまた責務を
混ぜてしまう。単一プロセスの中に複数の非同期な情報源が同居する設計では、この繰り返しは偶然
ではなく、構造的に起こりやすいと言えます。
四層モデルを定義した文書(ADR-032)自体にも、定義しただけでは終わらなかった事実が記録されて
います。6月30日から7月1日にかけての2日間で、21件の関連課題を一括して処理した記録が残って
います。そのうちの一つは、四層に分けたはずのImeBelief.input_modeが、実際にはpubのまま
公開されており、reducerを経由せずに外部から直接書き換えられる状態だったという指摘でした。
四層に分けるという原則は、文書の上では6月にはすでに存在していました。しかし、その原則を
コンパイラで強制する作業は、別の日にちで改めて必要になったということです。
この2日間で見つかった問題は、ImeBelief.input_modeの公開範囲だけではありませんでした。
一つは循環依存でした。本来は下位のモジュールが上位のモジュールを知らないまま動くべきところ、
engine::decisionという下位側のモジュールが、上位にあるはずのplatform::EffectOriginを
参照しており、依存の向きが逆転していました。もう一つはGod Object、つまり一つの構造体に
複数の責務を抱え込ませすぎた状態です。PlatformStateは約900行、Outputは約2000行、
Runtimeは約800行にまで膨らんでおり、いずれも一つの型が複数の役割を同時に背負っていました。
この課題整理では、ほかに二つの代替案が検討され、いずれも却下されています。
段階的リファクタを先延ばし
→ 採用しなかった。循環依存は放置するほど絡まり、一度解消しないと次のリファクタのコストが
指数的に増える。
God Objectを「整理」だけして分割しない
→ 採用しなかった。責務の混在が根本原因であり、コメント整理では解決しない。
「後で分ければよい」も「コメントで整理すればよい」も、どちらも責務を型として分けることの
代わりにはならないと判断されたということです。最終的に、層をまたぐ禁止事項は
docs/layer-boundaries.mdへ、C-1からC-6までのカテゴリとして書き出されました。「この層から
この層へは直接書き込まない」という制約が、文章としてではなく、grep一発で違反箇所を洗い出せる
形で残されたということです。原則を書くだけでなく、原則の違反を機械的に検出可能にするところ
まで踏み込んで、ようやく四層は維持され始めています。
四つに分けても、まだ言えないことが残った
四層に分けたことで、少なくとも「何を書く場所か」は明確になりました。Intentはユーザーの意思、
Observationは外部からの報告、Transitionは進行中の変更、Barrierは最後の許可判断です。それぞれの
役割は、もう一つの巨大な構造体の中で溶け合ってはいません。
しかし、四層に分けただけでは答えられない問いが一つ残りました。Observationには「いつ観測
されたか」という時刻はあっても、「どのフォーカス文脈についての観測か」という区別が
ありませんでした。フォーカスが切り替わった直後に、切り替わる前の文脈について届いた観測は、
四層のどこに置いても、新しいのか古いのかを判定する手段を持ちません。
時刻だけでは足りない理由は単純です。観測が届くまでの時間は、CPU負荷やOSのスケジューリング
次第で伸び縮みします。「何ミリ秒前の観測か」を基準にする限り、基準そのものが状況によって
ずれます。必要なのは経過時間ではなく、「その観測がどの世界について語っているか」という
文脈の一致でした。第4章がTransitionの進行度を時間で代用して失敗したのと、同じ形の失敗が、
ここでもう一度起こりうるということです。
四つに分けた箱のどれもが、まだ「この観測はどの世界についての観測か」を表現できていなかった
ということです。この不足は、四層モデルの欠陥ではありません。四層モデルが果たすべき役割を
きちんと果たしたからこそ、次に解くべき問いがどこにあるかが、はっきり見える形で残った
ということです。
意図・観測・遷移・障壁という四つの名前を得たことで、私たちはようやく「何を混同していたか」を
言葉にできるようになりました。しかし、言葉にできたことと、それが二度と混ざらないことは、
別の話です。四層のそれぞれが、いつの世界について語っているのかを、まだ誰も保証していません。
設計原則
原則: 名前を変えるだけでは、責務は分解されない。
適用条件: 一つの構造体やフィールドに、複数の異なる時間軸・複数の書き込み主体からの情報が
混在し、修正のたびに同じ場所へ分岐が増え続ける場面に当てはまる。改名や再構成を繰り返しても
同じ種類のバグが形を変えて戻ってくるなら、責務そのものがまだ分かれていない兆候である。
実装の形: まず、混在している役割を「誰が書くか」で分類し、役割ごとに別の型を用意する。
次に、書き込める経路を一つに絞る。その制約は、コメントではなくprivateフィールドやアクセス
制御によって、コンパイラやgrepで機械的に検出できるルールへ変換する。文書化しただけの原則は、
実装がそこから外れても気づけない。
保証しない範囲: 型を分けることは、責務が混ざらなくなることを保証するだけである。分けた
型同士がどの文脈・どの世代の情報を指しているかは、型を分けただけでは何も保証しない。責務の
分解と、文脈の一致は、別の問題として残る。
第11章:エポック

実装は84分で終わりました。2026年7月4日、604cf99(06:58)・a0e4ec7(07:42)・008f039(08:20)という3つのコミットが、その日のうちに積み重ねられています。しかし、この84分を可能にしたのは、それ以前の3か月間、同じ問題に何度も失敗してきた記録でした。
「一箇所に集約すれば直る」という発想は、前章までにすでに2度、別の名前で試され、そのたびに同じ症状が形を変えて戻ってきています。84分という数字だけを見れば、鮮やかな解決に見えます。しかし本章が描きたいのは、その84分の前に横たわっていた3か月分の失敗の方です。
3つのコミットの中身は、それぞれ役割が違います。604cf99は観測を受理する層そのものを新設し、a0e4ec7はその受理結果を型として保証したうえで、読み出し側にepochによるフィルタを追加し、008f039はADR-077として記録を残しています。実装が短時間で済んだのは、何を作るべきかが、3か月分の失敗を経てすでに明確だったからです。逆に言えば、何を作るべきかが定まっていなかった3か月の間は、同じ問題の周りを、名前を変えながら回り続けていたことになります。
本章の問いは一つです。観測が古いことを、どう構造的に拒否するか。
フォーカスは、対話の途中で一度変わっていた
発端は、ALT+TABによるウィンドウ切り替えでした。LINE(Qt/ImmCrossを使うアプリケーション)にフォーカスがある状態から別のウィンドウへ切り替えると、awaseのエンジンがローマ字直接入力(Engine OFF)へ落ちる、という不具合が報告されています。
原因はADR-077に記録されています。Windowsは、ALT+TABでウィンドウを切り替える間、候補を並べて選ばせるための専用のUI(XamlExplorerHostIslandWindowやForegroundStagingといった、ユーザーには直接見えないウィンドウ)へ、ごく短い間だけフォーカスを移します。この中継地点は、ユーザーの体感としては一瞬で通り過ぎるものですが、OSの内部では確かに一つのフォーカス先として存在しています。このタイミングで起動していたImmCrossProbe(非同期のプローブ)が、経由ウィンドウを対象にIME状態を読み取り、falseを返していました。当時の実装はこのfalseを高い信頼度で即座に採用し、結果としてエンジンが停止していました。
これは、センサーが嘘をついた話ではありません。センサーは正直に、しかしもう関係のない場所を見て、その通りに報告していました。観測は正しかったのです。ただしその観測は、もう関係のない文脈についての観測でした。
この不具合の厄介さは、再現条件にあります。ALT+TABの操作自体は毎回同じでも、非同期プローブの完了がタスクスイッチャーの表示と重なるかどうかは、そのときのCPU負荷やスケジューリングに左右されます。重ならなければ何も起きません。重なった瞬間にだけ、エンジンが停止します。単一のプロセスの中に、タイミング次第で結果が変わる、小さな分散システムが隠れていたことになります。
この時系列を図にすると、次のようになります。

この図から読み取れることは一つです。プローブが返したfalse自体は、そのプローブが見た対象について嘘をついていません。壊れていたのは、値ではなく、値がどの文脈に属していたかを誰も記録していなかったことでした。
旧設計と新設計の違いは、判断に使う材料の違いに現れています。旧設計が見ていたのは、観測がどれだけ新しいか、という1つの軸だけでした。新設計が見ているのは、観測がいつ生まれたかに加えて、観測がどの世代のフォーカスの下で生まれたか、という2つ目の軸です。この2つ目の軸を持たない限り、どれだけ精密に時間を測っても、この不具合は解けませんでした。
ここで分かるのは、経過時間だけでは足りないということです。仮にこのプローブが完了までにかかった時間がごく短くても、対象がすでに別のウィンドウへ切り替わっていれば、その観測は無効です。必要なのは「何ミリ秒前の観測か」だけではなく、「どの文脈で得られた観測か」でした。
同じ「新しさ」という言葉が、二つの違う量を指していたことになります。一つは、観測が完了してから経過した時間です。もう一つは、観測が開始された時点のフォーカスと、いまのフォーカスが同じ世界を指しているかどうかです。前者は時計があれば測れます。後者は、フォーカスが変わったという出来事そのものを数えない限り、測る手段がありません。
先に試した猶予時間は、三箇所にコピーされた
この時点で、awaseがIME状態を把握するために動員していたプローブは、7種類に増えていました。TSFとIMM32という2つのAPI系統をまたぎ、同期・非同期・イベント駆動という異なる性質を持つプローブを並行して使う設計です。単独のプローブでは、すべての状況を十分な精度で捉えられなかったため、複数の情報源を組み合わせる必要がありました。組み合わせが増えるほど、どの観測をどこまで信じるかという判断も、複雑になっていきます。
最初に採られた対策は、時間による猶予でした。shadow_on && probe_age_ms < 200msという条件を満たす間だけ、ImmCrossProbeが返すfalseを抑制します。手元にある情報が「観測が完了した時刻」と「直前のIME状態」だけであれば、時間の近さを信頼度の代わりに使うのは、最初の一手としては自然な選択でした。実際、この対策は報告された症状を実際に抑え込んでいます。
当時、awaseには7種類のプローブがあり、それぞれ次のような抑制条件を持っていました(表中のGJIは
Google Japanese Input、Google 日本語入力の略で、競合する別会社の日本語入力エンジンを指します)。
| プローブ | 種別 | 信頼度 | 抑制条件(当時) |
|---|
| ImmCrossProbe | 非同期 | High | shadow_on && probe_age < 200ms |
| FocusProbe | 同期(first-key) | Low | 同上(コピー) |
| ObserverPoll | 同期(500ms周期) | Medium | 同上(コピー) |
| GJI | イベント駆動 | Medium | last_ioタイムスタンプ |
| TSF Observer | イベント駆動 | Medium | 観測のみ、desired不変 |
| HwndCache | 同期 | Low | なし |
| ImmGetOpenStatus | 同期 | High | なし |
「信頼度」の列は、そのプローブが返す値を、他の情報と照らし合わせずにどこまで信じてよいかを表しています。Highは通常もっとも信用され、awaseの判断を単独で左右する力を持っていました。ImmCrossProbeが抱えていた問題は、信頼度そのものの設定が誤っていたのではなく、信頼度の高さと、観測が属する文脈の古さが、別々の軸であるにもかかわらず、区別されずに扱われていたことです。
この表が示すのは、症状は抑えられても、判断の根拠が3箇所に複製されていたという事実です。ADR-077自身も、この対策には構造的な欠陥が残ると認めています。欠陥は、大きく3つに分けられます。
第一に、200msという数字自体に根拠がありませんでした。ALT+TABの操作からタスクスイッチャーの表示までにかかる時間は、そのときのCPU負荷やプロセス数によって伸び縮みします。負荷が高い環境では、200msを超えてから古い観測が届くことも起こり得ます。第二に、同じ条件式がImmCrossProbe・FocusProbe・ObserverPollの3箇所に、ほぼそのままコピーされていました。どれか1箇所を直しても、残り2箇所は直りません。第三に、「この観測は信用できるか」という判断そのものが、特定の1箇所に集約されておらず、コードベース全体に散らばっていました。
一見、不具合は直っています。しかし直ったのは症状であって、症状を生んでいた構造ではありませんでした。時間を根拠にする限り、CPU負荷やスケジューリングの揺らぎが変われば、同じ種類の不具合はいつでも再発しえます。必要だったのは、時間の長さを測ることではなく、観測がどの文脈に属していたかを直接識別することでした。
フォーカスの世代を数字にする
ここでいったん、「epoch」という言葉そのものを確認しておきます。epoch(エポック)は、この章では「区切りが変わるたびに1ずつ増える通し番号」という意味で使います。フォーカス(いまキー入力を受け取っているウィンドウ)が切り替わるたびに1つずつ増えていく番号だと考えれば十分です。「いまが何回目の区切りにいるか」を数えるための値です。時計のように時間の長さを測るのではなく、区切りが起きた回数だけを数えます。
そこで導入されたのが、この考え方を体現したFocusEpochという世代番号です。FocusStore::focus_epochというただのu64で、on_focus_process_changedが呼ばれるたびにwrapping_add(1)で1つ増えます。増える条件は一つだけです。フォーカス先のプロセスが変わったときだけ、この番号は進みます。
非同期プローブ(ImmCrossProbe・FocusProbe)は、起動(spawn)する瞬間に、そのときのFocusEpochを1つだけ記録します。これがImmLikeTicketです。プローブが完了して結果を届けようとするとき、admit()がこのチケットに刻まれたepochと、いまの最新epochを突き合わせます。
記録するタイミングと、比較するタイミングは、意図的に分けられています。記録するのは、観測を始めた瞬間です。比較するのは、観測が終わり、その結果を実際に使おうとする瞬間です。この間にフォーカスが変わっていなければ、2つのepochは一致します。変わっていれば、一致しません。一致・不一致という単純な判定だけで、「この観測はいまの文脈に属しているか」という問いに答えられることになります。
ImmLikeTicketという名前が示す通り、この仕組みはImmCrossProbe専用ではありません。「起動時のフォーカスを覚えておき、完了時に照合する」という性質を持つプローブであれば、FocusProbeのように種類が違っても、同じチケットを共有できます。7種類あったプローブのうち、この性質を持つものだけが対象になり、GJIやTSF Observerのようにイベント駆動で届くプローブは、次の節で扱う別の理由により対象から外れています。
ImmLikeTicketはspawn時のフォーカス世代(focus_epoch)を1つだけ保持する軽い値です。
一方AcceptedObservationは非公開フィールドを持ち、この型の値を外部から直接組み立てることは
できません。手に入れる方法はadmit()を通ることだけです。
pub fn admit(self, current_epoch: FocusEpoch) -> Admission {
if current_epoch != self.focus_epoch {
REJECTED_EPOCH_MISMATCH.fetch_add(1, Ordering::Relaxed);
return Admission::Reject(RejectReason::FocusEpochChanged { .. });
}
Admission::Accept(AcceptedObservation { focus_epoch: current_epoch, _private: () })
}
spawn時のepochと現在のepochが一致しなければReject、一致すれば非公開フィールド経由で
AcceptedObservationを組み立ててAcceptを返す。この2択だけが、外部からAcceptedObservation
を得る唯一の経路です。
拒否された観測は、ただ捨てられるだけではありません。REJECTED_EPOCH_MISMATCHというカウンタが1つ増えます。これは、この不具合が実際にどれくらいの頻度で起きていたか、直してから再発していないかを、あとから確認できるようにするためのものです。判定を1回の分岐で終わらせず、その結果を数え続けるところまで含めて、設計の一部になっています。
admit()は、引数として渡された二つの数値を比べるだけの、副作用のない関数です。実際に
フォーカスを切り替えたり、非同期プローブを本当に走らせたりしなくても、focus_epochと
current_epochに好きな値を入れて呼び出すだけで、一致する場合・しない場合の両方を
テストコードの中で確認できます。3か月の間に2度、同じ問題を別の名前で解決しようとして
失敗してきたのは、この判定がコード中に散らばっていて、実機を使った再現でしか確かめられ
なかったことも一因でした。一つの純粋な関数にまとまった後は、この判定そのものを実機なしで
検証できます。
この型が実装された604cf99の変更行数は、わずか+111行、3ファイルでした。3箇所にコピーされていた時間ベースの推測を、小さな型を1つ足すことで置き換えられたことになります。行数の小ささは、設計が簡単だったことを意味しません。何を1つの値として運ぶべきかが定まっていれば、実装そのものは小さくて済む、ということだけを示しています。
受理は証明にすぎない、鮮度は使うたびに確かめる
AcceptedObservationという型の価値を、正確に言い当てる必要があります。この型が実現したのは、動的な検査を静的な検査に置き換えたことではありません。動的なepoch照合を一度通過したという事実を、後段まで運べる値にしたことです。型によって鮮度が永久に保証される、という意味ではありません。
この型が持つ_private: ()というフィールドは、admit()を経由しない限り、この型の値を外部から直接組み立てられないようにしています。将来プローブを追加する実装者は、書き込み関数のシグネチャにAcceptedObservationが要求されているのを見て、「admissionを通らなければならない」と自然に気づく構造です。しかしこれは、構築された瞬間に世界がどうだったかの証明であって、その1マイクロ秒後にフォーカスが変わっていないという保証ではありません。
型が保証できるのは、過去の一点についての事実だけです。「この値は、確かに一度admit()という関所を通った」という、構築時点の証明にとどまります。そこから先、その値が実際に使われるまでの間に世界がどう変わったかについて、型は何も語りません。型検査はコンパイル時に一度だけ行われますが、フォーカスはプログラムが動いている間、いつでも変わり得るからです。
もし本当に動的検査を静的検査へ置き換えられていたなら、コンパイラは「このAcceptedObservationは、使われる時点でも新鮮である」と保証できたはずです。しかし、コンパイラは実行時にフォーカスが変わるかどうかを知りません。知り得ないものを、型だけで保証することはできません。AcceptedObservationが本当に置き換えたのは、「新鮮であるという保証」ではなく、「過去に一度、正しい手順で確認された」という、来歴の記録の仕方です。
実際の設計は、次の3層で完成しています。
書き込み時: ImmLikeTicket::admit() → AcceptedObservation(型による証明)
ストア時: ImeObservation.focus_epochに記録(来歴の記録)
読み出し時: derive_open()がepochフィルタを再適用(実行時の再照合)
3層はそれぞれ、別の失敗を防ぐために存在します。書き込み時の型がなければ、admissionを経由しない値がどこかから紛れ込む経路を防げません。ストア時に世代を記録しなければ、observationがどの文脈で生まれたのかを、あとから参照する手段がなくなります。読み出し時の再照合がなければ、受理された時点では正しかった判断が、そのまま古くなった後も使われ続けます。1つでも欠けると、残り2つだけでは鮮度を守り切れません。
鮮度を最終的に守っているのは、型ではなく3層目です。derive_open()(observation_store.rs)は、observationを読み出すたびに、記録済みのepochといまのepochを再び比較します。
let is_epoch_ok = |o: &ImeObservation| match o.source {
ObservationSource::ImmCrossProbe | ObservationSource::FocusProbe => {
o.focus_epoch == current_epoch
}
_ => true, // GJI/ObserverPoll/TSFはイベント駆動のため対象外
};
matchの最後の腕が示す通り、この照合はすべてのプローブに及んでいるわけではありません。イベント駆動のGJI・ObserverPoll・TSF Observerはepoch照合の対象外で、FRESHという3000msの鮮度ウィンドウだけで守られています。3000ms以内にフォーカスが変わり、たまたま新しいフォーカス先の状態と一致してしまえば、この機構をすり抜ける可能性は理論上残ります。イベント駆動の観測は、いつ届くかをこちらが選べないため、spawn時にepochを刻むという仕組みそのものが馴染みません。ここは、epoch導入によっても閉じ切っていない隙間として、意図的に残されています。
呼び出す側のeffective_open()(ime_model.rs)は、呼ばれるたびにInstant::now()で新しく評価されます。
pub fn effective_open(&self) -> bool {
let base = if self.has_user_explicit_intent() {
self.desired_open
} else {
self.observations.derive_open(Instant::now()).unwrap_or(self.desired_open)
};
self.force_guards.effective_open(base)
}
一度受理したら終わり、という設計ではありません。使うたびに、いまの世界と照合し直す設計です。型は、不正な構築を防ぐ関所にすぎず、鮮度そのものの証明書ではありません。鮮度を保っているのは、使う直前にもう一度確かめるという、地味な実行時の一手間です。
effective_open()は、awaseの中でIMEが開いているかどうかを尋ねられるたびに呼ばれます。呼ばれる回数を減らすために結果をキャッシュする、という選択肢もあり得ました。しかし、キャッシュした瞬間から、その値は少しずつ古くなっていきます。ここでは逆に、キャッシュを持たず、尋ねられるたびにderive_open()まで遡って計算し直す設計が選ばれています。速さよりも、答えが常にいまの文脈に対するものであることを優先した結果です。
似たような工夫は、日常のアプリにも隠れています。フードデリバリーの注文を思い浮かべてください。アプリは注文をドライバーAに割り当てます。ところがドライバーAの電波が悪く応答がないため、アプリは注文をドライバーBに割り当て直し、Bが実際に商品を届けます。しばらくして電波が戻ったドライバーAのスマホが、自分がまだ担当だと思い込んだまま「配達完了」を報告してきたら、記録はどうなるでしょうか。これを防ぐ仕組みは単純です。注文を割り当てるたびに「今回は何巡目の割り当てか」という番号を振り、アプリは最新の巡目からの報告だけを受け付けます。ドライバーAの報告は古い巡目のものなので、後から届いても無視されます。
この「巡目の番号を振り、古い巡目からの報告は無視する」という工夫自体は、awaseが発明したものではありません。Googleが2006年に発表した、複数のサーバーの間で「いま誰が書き込んでよいか」を管理する分散ロックサービスChubbyや、Kubernetesが持つresourceVersionという仕組み、複数の候補から一人のリーダーを選ぶRaftというアルゴリズムの「任期」番号は、いずれも同じ骨格を持っています。オンライン対戦ゲームの通信でも、ネットワークの遅れで前後してしまう操作を、フレーム番号のような通し番号で並べ直し、古い番号の操作は捨てる、という仕組みが広く使われています。番号を振って古いものを捨てるという着想そのものに、awase固有の新しさはありません。
ただし一つだけ、まぎらわしい「似て非なる」話に触れておきます。Rustというプログラミング言語のエコシステムにはcrossbeam-epochという、名前がよく似た道具があります。しかしこちらが解いているのは、複数の処理が同時に使っているかもしれないメモリを、いつ安全に片付けてよいかという、まったく別の問題です。同じ「epoch」という言葉が、たまたま違う二つの問題に使われているだけで、FocusEpochが解決した課題とは関係がありません。
では、awaseのFocusEpochとAcceptedObservationに、あえて工夫と呼べる点があるとすれば何でしょうか。それは番号を比べること自体ではなく、「番号が一致することを確認しない限り、そもそも値を受け取れない」という制約の掛け方です。多くの仕組みは、番号が古いことに後から気づけるようにはなっていても、古い番号のまま先に進むこと自体は止めません。改札を思い浮かべてください。古い定期券でも一応通れてしまい、あとで駅員が記録を見て「これは無効でした」と気づくのと、そもそも改札のバーが開かず物理的に通れないのとでは、守り方の強さが違います。awaseの設計は後者で、admit()という関所を通らない限り、AcceptedObservationという値そのものが、コードのどこにも存在できません。この「そもそも作れなくする」という徹底の仕方は、狭い工夫ではありますが、実質のあるものです。
一方FocusEpochは、複数の主体を排除するための仕組みではありません。単一のプロセスの中で、いまの自分が少し前の自分と同じ文脈にいるかどうかを識別するために使われています。相手は別のプロセスではなく、少し前の自分自身です。フードデリバリーの例で言えば、ドライバーが複数いて誰が正しいかを決める話ではなく、同じドライバーのアプリがたった一人で、「さっき受けた注文と、いま受けた注文は、同じ巡目か」を確かめているようなものです。競合する主体を1つに絞ることと、1つの主体の中で文脈の世代を区別することは、似た仕組みでありながら、解いている問題が違います。
分散システムでは、複数のノードが同時に書き込みを試み、そのうちどれを正とするかが問題になります。awaseのプロセスは1つしかなく、競合する別プロセスはいません。競合しているように見えるのは、同じプロセスの「いまの自分」と「少し前の自分が起動した非同期処理」だからです。単一プロセスの内部にも、この種の擬似的な競合は生まれ得るということが、この章が示した事実です。
Physical AIへの接続
観測にも、文脈の世代がある
この構造は、日本語入力エンジンに固有のものではありません。次の対応表は、awaseで起きたことをそのままロボティクスの語彙に置き換えたものです。
| awase | ロボティクス/Physical AI |
|---|
| Windows IMEの内部状態 | ロボット・対象物・人間・環境の隠れ状態 |
| ImmCross/Focus/ObserverPollなどのプローブ | カメラ・LiDAR・IMU・SDK状態・ネットワークテレメトリ |
FocusEpoch | mission epoch / route epoch / control-owner epoch |
AcceptedObservation | いまの制御文脈で使ってよいと認められた観測 |
本書のはじめにで述べた命題を、もう一度ここに置きます。
外の世界を相手にするソフトウェアでは、観測したことと、いま実行してよいことを分けなければならない。
FocusEpochとAcceptedObservationは、この命題をIMEという一つの領域の中で、具体的な型として実装したものです。観測したことと、いま実行してよいことの間には、文脈が同じかどうかという確認が挟まっています。カメラが対象を正しく捉えていても、その対象がミッションの前の段階に属するものであれば、いまの判断に使ってよい観測ではありません。たとえば、経路変更の直前に取得した障害物の位置は、経路変更の直後には、もう存在しない前提に基づく情報になり得ます。センサーは嘘をついていません。ただ、もう関係のない世代についての情報を、正直に返しているだけです。IMEの世界で起きていたのと同じ問い、観測がどの文脈の世代に属するか、が形を変えて現れます。
このときはまだ気づいていませんでしたが、著者が別に設計しているGo2 Runtimeというロボット向けランタイムにも、よく似た観測の型がすでに存在していました。その先で何を見つけたかは、終章で扱います。
ここまでの話が守っているのは、observationがbeliefへ届くまでの経路だけです。beliefが確定したあと、それを実際のOS操作(Effect)としてキューに積み、送信する経路については、まだ何も述べていません。ImmCrossの非同期送信は、一度呼び出されれば最後まで実行され、送信後に取り消すことはできません。
観測の経路には、書き込み時の型保証、ストア時の来歴記録、読み出し時の再照合という、3つの防御が重なっています。効果の経路には、いまのところこれに相当する防御がありません。beliefがいったん正しく更新されても、そのbeliefをもとに生成されたコマンドが、送信されるまでの間にフォーカスが変わっていないという保証は、まだどこにもありません。
3か月かけて仮説を立て直し、84分でそれを型にした結果が、ここまでの仕組みです。古い観測は、拒否できるようになりました。しかし、古い観測からすでに作られたコマンドは、まだキューに残り得ます。
設計原則
型は、かつて検査を通ったことの証明であり、いまも正しいことの証明ではない。
適用条件: 非同期に完了する観測や処理があり、完了した時点で前提がすでに変わっているかもしれない場面に適用する。開始から完了までが1つの同期的な呼び出しの中で完結し、その間に前提が変わりようのない処理には、過剰な仕組みになる。
実装の形: 増加するだけの世代番号を1つ用意し、文脈が切り替わったときにだけそれを進める。観測や処理を開始する時点で、いまの世代番号を記録する。受理した値はプライベートフィールドを持つ型としてのみ生成できるようにし、外部からの直接構築を防ぐ。そのうえで、値を実際に使う瞬間には、記録した世代といまの世代を必ずもう一度突き合わせ、一致しなかった回数を数え続けられるようにしておく。
保証しない範囲: この構造が防ぐのは、古い文脈の観測がそのまま採用されることだけである。イベント駆動で届く観測など、世代照合の対象に含めていない経路には及ばない。また、観測をもとにすでに生成された作用が、送信される直前に古い文脈のまま実行されてしまうことは、この構造だけでは防げない。守れるのは、値が生まれた瞬間の文脈だけであり、その値が使われるまでの間も文脈が同じままだという保証は、使う側が毎回確かめない限り得られない。
第12章:それでも壊れる条件

送信されたコマンドは、もう存在しない場所に向かっていた
次の手順を、コードの上でたどってみます。たとえば、ALT+TABで別のウィンドウへ切り替える
場面を考えます。切替の直前、あるウィンドウにフォーカスがある状態で、IMEをオフにするという
決定が下ります。決定はOSへの非同期コマンドとして、キューに置かれます。コマンドが実際に
送信される前に、ユーザーが別のウィンドウへフォーカスを移します。そして、最初の決定に基づく
コマンドが、いま存在しないはずの古いフォーカス先へ向けて送信されます。
利用者から見れば、これは次のキー入力が、狙った通りに変換されない、という一瞬の不具合と
して現れるはずです。ただし、この手順を実際に踏んだという記録は、開発ログのどこにも
残っていません。ここまでの追跡で分かったのは、コードを読む限り、これを止める仕組みが
存在しない、という事実だけです。送信されてしまえば、そのコマンドは取り消せません。
OSへ渡った命令は、戻ってきません。
信念(belief)であれば、後から間違いに気づいて書き直せます。しかし、一度OSへ送信された
命令は、awaseの内部にあるどんな値を書き直しても、なかったことにはできません。書き直せる
対象と、書き直せない対象を、同じ強さで守る必要はないはずです。ところが、書き直せない
対象のほうを守る仕組みは、まだありません。
前章で見たFocusEpochは、まさにこの種の事故を防ぐために作られました。それなら、なぜこの
経路だけが素通りするのでしょうか。答えは単純です。FocusEpochが守っている場所と、この
コマンドが通る場所が、別の場所だからです。
前章を書き終えた時点では、観測にまつわる問題は解決したかのように見えました。しかし、
観測は目的そのものではありません。観測は、最終的にOSへ渡す命令を正しく組み立てるための
手段です。手段が正しくなったからといって、その先の手続きまで正しくなったとは限りません。
実は、この点には前章の時点でも一つだけ、答えの出ないまま残されていた問いがありました。
「admit()でエポックを照合した後、AcceptedObservationをストアへ保存してから実際に
使うまでの間に、新しいエポックへ遷移するケースを、いまの実装は実際にどう防いでいるのか。」
前章ではこの問いに触れず、コードを読んで確かめる作業を本章へ持ち越していました。
観測は使うたびに洗い直される。命令はそうではない
確認は、二つの経路に分けて行いました。
一つ目は、観測が受理されてからストアへ書き込まれるまでの経路です。ImmLikeTicket::admit()
の呼び出しと、ストアへの書き込みは、with_app(|app| { ... })という一つのクロージャの中で
連続して実行されています(focus_tracking.rs:349、key_pipeline.rs:214,910)。awaseは
シングルスレッド・協調的なスケジューリング(win32_async::spawn_local)で動いており、
この区間に割り込みは入りません。受理してから保存するまでの間に、隙間はありませんでした。
そして前章で見た通り、derive_open()は呼び出しの都度、現在のエポックと記録済みの
エポックを再比較します。一度受理されて終わりではなく、使われるたびに、もう一度
洗い直されます。ここまでは、前章の設計が意図した通りに動いています。
二つの経路を並べると、再検証がどこで途切れているかが一目で分かります。

二つ目は、決定がOSへの実際の作用として送信されるまでの経路です。この経路は、そもそも
同期処理では済みません。IMEの切替をプロセスをまたいで反映させる呼び出しは、即座には
終わらず、完了までに時間を要します。だからこそ非同期のキューを経由する構造になって
いるのですが、非同期であるということは、送信を待っている間に世界が変わりうる、という
ことでもあります。runtime/executor.rsのdispatch_ime_set_openが担う、ImmCross向けの
非同期処理を確認しました。次のようになっています。
// 決定(open, origin)はここより前、effective_open() を使った同期処理で確定済み
win32_async::spawn_local(async move {
let ok = crate::ime::set_ime_open_cross_process_async(open).await;
// この await の間にフォーカスが変わっても、送信はキャンセルされない
...
});
openという値は、awaitに入る前にすでに確定しています。win32_async::spawn_localは
協調的なスケジューラ(複数の処理が、自分から一時停止しては順番に進行を譲り合う方式)の
上で動きます。await(結果が返るまでいったん待つ、という意味の構文)に入った瞬間、制御は
いったんイベントループへ戻ります。フォーカス変更を処理するイベントは、その間に先に
実行されます。送信を待っているopenという値は、別のエポックへ遷移したことを何も知らない
まま、待ち続けます。送信は無条件に実行されます。
完了後の処理はgenerationという値(apply命令を発行するたびに1つ増える番号。第6章で
導入した仕組みです)を照合しますが、これが守るのは「この完了通知を信じてよいか」という
一点だけです。「コマンドがすでに取り消せない形で送信済みである」という
事実そのものは、この照合の対象になっていません。この設計自体は、意図的に絞り込まれて
いました。ここで守ろうとしていたのは状態の一貫性であって、物理的な作用の発火可否
ではなかったのです。
この状態は、二つの独立したフェンシング機構(古い指示や古い通知を、無効なものとして
退ける仕組み)が並んでいる、と整理できます。
| 機構 | 対象方向 | 何を守るか | 何を守らないか |
|---|
| FocusEpoch / AcceptedObservation | 入力(観測の受理) | staleな観測がbeliefを汚染すること | ― |
| ImeTransition.generation | 出力(効果の完了記録) | staleな完了通知がbeliefを上書きすること | OSへの送信自体が、staleな決定に基づいて既に発火済みであること |
観測を信じる前にエポックを見る。完了通知を信じる前にgenerationを見る。この二つは対称的な
防御に見えます。しかし、まだ実行していない効果を、実行する直前にもう一度照合するという
三つ目の防御は、どちらの機構にも含まれていません。
観測の経路と、効果の経路とでは、保証の中身が違います。
| 対象 | 現在の保証 |
|---|
| 古い観測によるbelief更新 | epoch照合によってほぼ防げる |
| 利用時の古い観測 | derive_open()が呼び出しの都度、再照合する |
| 古い文脈で生成されたEffect | 保証されていない |
| dispatch直前の鮮度 | 保証されていない |
| 送信済みOSコマンドの取消 | 不可能 |
この表の上二行と、下三行の間には、明確な境界線があります。観測が信念を汚す経路は、
二重に守られています。信念が作用に変わり、OSへ送り出される経路は、まだ一度も
検査されていません。
なお、観測の経路にも隙間は残っています。GJI(Google Japanese Input、Google 日本語入力)・
ObserverPoll・TSF(Text Services Framework、Windowsの入力方式を仲介する仕組み)のような
イベント駆動の観測は、エポック照合の対象外です。これらは
3000ミリ秒の鮮度ウィンドウだけに守られています。ただしこれは観測経路に残る別の隙間であり、
本章の主題である効果経路の欠落とは区別しておくべきです。
「型で防げる」と言うとき、実際に防げている範囲を、こうして書き出しておく必要があります。
おおよそ塞がっているという手触りと、実際に塞がっている範囲は、別のものです。
まだ存在しない設計
先の表の下三行を埋めるとすれば、という以上の意味を持たない候補です。ここから先は、
実装済みの仕組みの説明ではありません。awaseのコードには、まだ存在しない設計です。
Effectにも観測と同じように、生成時点のエポックを持たせるという案があります。ただし、
エポックを値として持たせるだけでは十分ではありません。それを実際に照合する場所を、
どこかに用意する必要があります。
dispatchの手前に、通過しなければ送信できない関所を置くという案もあります。観測の受理で
使った形を、そのまま効果の送信にも当てはめるということです。AcceptedObservationに
対応する形で、AcceptedEffectのような型を用意し、その関所を通った値しか送信関数に渡せない
ようにする案も考えられます。関所を型で強制しておかなければ、送信経路が新しく増えるたびに、
同じ抜け道がまた生まれます。
二つの案は、同じ目的に対する強さが違います。値を持たせるだけの案は、照合を呼び出す側が
書き忘れれば、そのまま素通りしてしまいます。関所を型で強制する案は、関所を通っていない
値を型として受け付けないため、送信経路が増えても同じ抜け道が生まれにくくなります。
いずれの案も、狙いは一つです。決定からOSへの送信までの間に生じる間隙で、もう一度いまの
エポックやgenerationと照合し、一致しなければ送信そのものを取りやめる。取り消せなくなる
直前の地点を、最後の検査点にする、ということです。
もっとも、この検査点にも代償はあります。送信の直前にもう一度照合するということは、
その分だけ送信のタイミングが遅れる、ということでもあります。フォーカスが変わる頻度に
比べて、この遅れがどこまで許容できるかは、実装してみるまで分かりません。取り消せない
作用を防ぐことと、反応を遅らせないことは、同じ場所で綱引きになります。
本章を書いている時点で、これらはどの案も実装されていません。前章の84分とは違い、ここには
まだ答えがありません。エポックという発想そのものが、観測の側で一度、三か月かけてようやく
形になったことを思えば、効果の側にも同じだけの時間がかかっても、不思議ではないでしょう。
他分野への転用
空いた駐車スペースを見つけてから、車を取りに戻るまでの間
空いている駐車スペースを見つけて、少し離れた場所に停めた自分の車を取りに戻ったとします。
戻ってくる間に、別の車が先にそのスペースへ入ってしまうことがあります。確認した瞬間には
確かに空いていたのに、実際に使おうとする瞬間には、状況が変わっているということです。
この種の間隙は、ソフトウェアの世界では長らく知られてきました。「確認した時点」と「使う
時点」がずれることで起きる不具合は、TOCTOU(Time-Of-Check to Time-Of-Use、確認時点から
使用時点への時間差)と呼ばれ、CWE(Common Weakness Enumeration、既知のソフトウェアの
弱点を分類する体系)にも独立した項目(CWE-367)として登録されています。古典的な例は、
Unixのファイル操作です。あるファイルにアクセスしてよいかを確認してから、問題なければ
実際に開くという、一見自然な二段階の手順があります。この二回の呼び出しの間に、ファイルが
別のもの(たとえば別のファイルへのシンボリックリンク)へすり替えられると、確認したはずの
ファイルとは別のファイルが開かれてしまいます。
awaseのExecutor直前の空白は、この古典的なパターンの一変種です。admit()という確認と、
実際にOSへコマンドを送信するという使用が、非同期のawaitを挟んで離れているために、
確認時点で正しかった前提が、送信時点でも正しいとは限らなくなっていました。ファイルが
すり替えられるか、フォーカスが自然に切り替わるかという違いはあっても、「確認と使用の間に
間隙があれば、その間に前提が壊れうる」という形そのものは同じです。
この類似には、テストのしやすさという観点から見て、重要な非対称性もあります。admit()や
derive_open()のような純粋関数は、値を入れ替えて呼び出すだけでテストコードの中に再現
できました。しかしTOCTOUの間隙そのものは、狙って再現するのが極めて困難です。実際に
フォーカスが変わるタイミングと、awaitから処理が再開されるタイミングという、二つの
非同期な出来事が特定の順序で重ならなければ発現しないからです。純粋な関数へ切り出すという
工夫は、その関数の内部ロジックが正しいことは保証しますが、その関数を呼ぶタイミングそのもの
に潜む間隙までは保証しません。テスト容易性と、間隙の不在は、別の性質です。
そして、この「Executor直前の空白」は、後に調べたロボットランタイムにも存在していました。
設計原則
鮮度の再照合は、値を使う場所に置くだけでは終わらない。作用が取り消せなくなる直前の
場所にも、同じ再照合を置く必要がある。
適用条件: 決定から実行までの間に非同期の間隙があり、かつ実行後に取り消せない作用を
伴う経路に当てはまる。間隙のない同期処理や、実行後に取り消せる作用には当てはまらない。
実装の形: 送信を担う関数に、決定時点のエポックやgenerationを引数として持たせる。
送信の直前に、その値と現在の値をもう一度照合し、一致しない場合は送信そのものを取りやめる。
保証しない範囲: この再照合を置いても、判定から送信までの間にさらに間隙が生まれれば、
同じ問題は一段深い場所に移るだけである。境界をどこまで遡って検査するかは、有限の選択で
あり続ける。
終章:IMEからロボットへ

四本脚のロボットに、最後にコマンドを送る関数があります。名前はexecuteといいます。関数の中身は数行だけです。受け取ったトルク値を、そのまま送り出す(publish)だけです。
この関数が呼ばれる直前には、もう一つの処理があります。ウォッチドッグ(処理が一定間隔で反応しているかを見張り、途絶えていれば異常とみなす仕組み)が、直前のtick(制御プログラムが一定周期で繰り返す処理の一区切り)が生きているかどうかだけを確認します。観測がいつ取得されたものかは、ここでは確認されません。生きているかどうかと、新しいかどうかは、別の問いです。ここでは前者しか問われていません。
awaseのEffect dispatchの直前にも、よく似た形の一瞬がありました。キューに積まれたコマンドを送り出す直前、そこにあるのはOS側の受け口が生きているかという確認だけで、そのコマンドがどの文脈で生成されたかまでは、通常は問われません。
ロボットの場合、この一瞬の重みはIMEより重くなります。IMEが取り違えるのは一文字ですが、ロボットが取り違えるのは、地面を蹴る力の向きや大きさです。古い観測に基づく指示がそのまま実行されれば、ロボットは足場のない方向へ体重をかけてしまうかもしれません。取り消せないという性質は同じでも、取り消せなかったときの結果は、文字よりも重くなります。
前章の最後で、この「実行直前の空白」は「後に調べたロボットランタイムにも存在していた」と書きました。ここからは、その実物を見ていきます。相手は、著者が別に設計しているロボット(Unitree Go2)向けアプリケーションランタイム、Go2 Runtimeです。この問題が、本当にIME固有ではないのかを確かめます。
すでに観測の型はあった
Go2 Runtimeには、観測の鮮度を表す型が、すでに存在していました。
pub enum Observed<T> {
Fresh(T),
Stale(T),
Unknown,
Broken(BrokenReason),
}
Observed<T>は、値をそのまま保持するのではなく、その値が新鮮か、陳腐化しているか、そもそも取得できていないかを型で区別します。BrokenReasonにも、数値として不正(Nan)・観測が届かなかった(Lost)・複数の観測が食い違う(Contradiction)・想定した形式と一致しない(SchemaMismatch)という具体的な破損の種類が並び、「分からない」を一つの雑多な例外として握りつぶさない設計でした。awaseのshadow state・belief(第5章)で、センチネル値を専用の型へ置き換えた道筋と、発想の芯は同じでした。
信念の側にも、確信度と裏付けの種別を保持するBeliefView<T>という型がありました。確信度はUnknownからPeakまでの五段階、裏付けは直接観測か複数センサーの一致か推論かといった種別で表されます。鮮度の情報は、この型には含まれません。鮮度はObserved<T>の側だけが持つ、という一元化がすでになされていました。
BeliefView<T>という名前は、当初BeliefSnapshot<S,B>という形で設計され、後に現在の形へ整理されています。awaseでも、BeliefStoreという名前を何度か付け替えながら、観測から信念を導く仕組みを整理してきました(第6章)。二つのプロジェクトは、互いを知らないまま、同じ改名の道筋をたどっていました。設計が一度で定まらず、何度も呼び名を変えながら収束していくという経験は、どちらのプロジェクトにも共通していました。
複数の裏付けを重ね合わせて一つの確信度に落とし込む、という考え方も共通していました。awaseは、複数の観測源を信頼度で重み付けして一つの信念へ融合する場面がありました。Go2 Runtimeのevidenceという区分も、直接観測か複数センサーの一致かを見分ける点で、根は同じ発想でした。片方が先に思いついた技法ではなく、同じ種類の不確実性を相手にすれば、同じ形の解決策に行き着く、という例がここにもう一つ増えました。
設計者自身、危険性を知っていた
Go2 Runtimeの設計文書には、鮮度を無視することの危険性を指摘した一文が残っています。
「stale but not missing が最も陰湿」
これはField<T>という型――信念を経由しない生のセンサー値にも、鮮度と品質を必須で添付する型――の根拠として書かれたコメントです。文脈は、オドメトリ(自己位置の推定値)についてでした。オドメトリは速度ガバナ(算出した速度指令を安全な範囲に収まっているか最終確認する制御ロジック)が読み、将来の経路計画も読む値です。値が届かないことより、古い値がまだ届いているように見えることの方が危険だと、設計者自身がこの一文で認識していました。
値が消えれば、それを使う側は「ない」ことに気づけます。しかし値が古いまま届き続ければ、使う側には新しい値と見分けがつきません。速度ガバナは、数百ミリ秒前の位置を、いまの位置として使い続けてしまいます。これは、awaseで固定待機時間が状態表現の代わりにならなかった事情(第4章)と、根が同じ問題です。
つまり、この危険性は見落とされていたわけではありません。設計として言語化され、Observed<T>やField<T>という形で、観測を受け取る側の危険は、すでに一部が塞がれていました。この章で見ていく空白は、無知の産物ではなく、対処の届いていない範囲の問題です。
危険性を言語化していたことと、その危険性をすべての場所で塞いでいたことは、別の事柄です。この区別は、awaseの開発でも繰り返し確認したことでした。分からない状態をセンチネル値として握りつぶさないと決めたあとも(第5章)、その決定が及んでいない場所で、同じ種類の不具合が形を変えて再発しました(第6章)。設計原則を一度立てたことは、その原則があらゆる境界に適用されたことを意味しません。
それでも、実行直前の空白は塞がれていない
Executorは、検証済みのコマンドを実行するための境界です。
pub trait Executor {
fn execute(&self, cmd: &Go2Cmd<Checked>, ctx: &TickCtx) -> Result<(), ExecutorError>;
}
Go2Cmd<Checked>という型は、apply_safetyという検査を通過した証拠を運びます。しかし、この型が証明しているのは「検査を通過した」という過去の事実だけです。「いま実行してよい」という現在の事実は、証明していません。型は、検査の瞬間の写真であり、実行の瞬間の写真ではありません。
このADR(ADR-R009、「Executor Seam」という名で管理されている設計文書)は、以前は実装されていない仕様書の段階にとどまっていました。その後、2026年6月11日に仕様が固まり、同月26日には実装も完了しています。実際の呼び出し箇所を確認すると、executeの直前にあるガードは、ウォッチドッグによる生存確認だけでした。世代(epoch)を照合するような、鮮度の再検証は行われていません。実装が仕様書の段階から先へ進んでも、この空白は自然には埋まりませんでした。設計として意図的に埋めない限り、実装の進行だけでは解消しない種類の空白だということが、ここで裏付けられました。
起こりうる失敗の形を、一つだけ挙げておきます。安全性検査を通過した時点では、コマンドは正しい前提のもとで組み立てられていました。しかしキューで順番を待つ間に、ロボットの姿勢や周囲の状況は変化しているかもしれません。executeは、その変化を知る手段を持たないまま、検査時点の前提でコマンドを送り出します。これは、awaseでフォーカスが変わったあとに古い文脈のコマンドが送信されていた事情(第12章)と、同じ形をしています。
awaseとGo2 Runtimeを並べると、同じ形の空白が見えてきます。
| awase | Go2 Runtime |
|---|
| IME状態の観測 | センサー・WorldStateの観測 |
| FocusEpoch | TickId(tick単調カウンタ。epoch照合には未使用) |
| Effectのdispatch | ロボットコマンドのexecute |
| 取消不能なOS操作 | 取消困難な物理的な動作 |
| dispatch直前の空白 | execute直前の空白 |
対応関係を図にすると、FocusEpochだけが「構造は同じだが用途が違う」という、少し
異なる形の一致になっていることが分かります。

TickIdは単調に増加するという構造こそFocusEpochと同じですが、コマンドの世代と照合する用途には使われていません。カウンタという道具はすでにそこにありながら、観測を受け入れてよいかを判定する関所(admission gate)としては使われていない、という状態です。
なお、Go2 RuntimeにはControlLeaseという、複数の操作者の間で操作権を切り替える仕組みも別に存在します。これはフェンシング(古い操作者からの指示を無効化し、いま操作してよいのは誰か一人だけに確定させる排他制御の方式)と呼ばれる考え方にもとづきます。Gateway側の早期フィルタと、Runtime tick側の最終判定という二段構えで、いま誰が操作してよいかを決める設計です。これは、一人の操作者のtick内で観測が陳腐化する、ここで扱っている問題とは別物です。フェンシングが守るのは「誰の指示か」であり、ここで足りていないのは「いつの観測に基づく指示か」を守る仕組みです。
FocusEpochの構造を持ち込めるとしたら
awaseがAcceptedObservationで解いた問題は、「admitを通らない限り、その値を構築できない」という制約を型に持たせることでした。同じ発想を、Go2Cmd<Checked>とTickCtxの間に持ち込めないか、という問いが自然に浮かびます。
TickCtxはすでにtick単調カウンタを持っています。apply_safetyを通過した時点のtick番号をGo2Cmd<Checked>に刻み、executeを呼ぶ直前で現在のtick番号と照合する。差が許容範囲を超えていれば実行せず、破棄するか再検証へ回す。これだけで、「検査を通過した」証明と「いま実行してよい」証明の間にあった空白は、構造として塞がれます。
ここで示したのは概念上の設計であり、Go2 Runtime側にまだ実装されているものではありません。awaseで実際に機能した型の作り方が、別のコードベースにもそのまま持ち込める形をしている、という見通しにとどめておきます。
どこまで一般化できるか
ここまでの観察から確実に言えるのは、一つのことだけです。IMEという領域で見つかった構造上の空白と同じ形のものが、ロボットランタイムという別の領域でも、独立に見つかったという事実です。
あらゆるPhysical AIランタイムに共通する一般原則だとまでは、まだ言えません。確認できたのは一つの実装だけであり、二つの独立した事例が一致したという段階にとどまります。「型は過去の証明であって、現在の証明ではない」という言い方は、この一致から導ける教訓の候補ですが、まだ本書の外にある多くの系で確かめられたわけではありません。
同じ形の空白は、IMEとロボット以外にも現れうる場所があります。GUIを自動操作するツールが、画面の状態を読み取ってからクリックを送るまでの間。分散システムのクライアントが、古いリーダー情報をもとにリクエストを送ってしまう間。これらは類推であり、確認済みの事実ではありません。この本で示せるのは、二つの独立した実装で同じ空白が見つかったという範囲までです。それ以上の一般化は、今後の観察に委ねます。
幸い、この空白は机上の議論にとどまりません。Go2 RuntimeのSimIoという模擬入力の仕組みは、関節の位置・速度・トルクといった状態を注入するinject_joint_stateのような関数を備えています。現時点では、注入した観測をそのまま返すだけの、単純な作りです。遅延・順序の入れ替え・重複したコマンドを模擬する専用の仕組みは、まだありません。しかし、admissionを通過した直後に古い観測を差し込み、executeが呼ばれるまでの間にそれがどう扱われるかを観察する実験であれば、いまのSimIoを少し拡張するだけで組めます。証明ではなく再現から始める、という本書の姿勢は、ここでも変わりません。この検証は、今後の課題として残っています。
最初の一文字に戻って
本書は、最初の一文字がローマ字のまま残った、という異常から始まりました。原因は、変換ロジックの誤りではありませんでした。IMEがいまどういう状態にあるかを、こちらが正しく把握できていなかったことでした。
その後、千六百を超えるコミットを重ねた道のりは、この一つの事実を、さまざまな場面で繰り返し学び直す過程でした。観測は、常に少し過去のものです。実行は、常にいまこの瞬間に対して行われます。この二つを同じ値として扱ったとき、awaseは何度も壊れました。そして、この二つを型として分けたときにだけ、壊れ方が減りました。
Go2 Runtimeという、著者と面識のない設計者が別につくった環境でも、観測と実行の間には同じ形の空白が残っていました。二つのプロジェクトは、示し合わせたわけではありません。それでも、同じ場所に、同じ形の問題を見つけました。IMEの一文字も、ロボットの一歩も、実行してしまえば取り消せません。取り消せない一歩の直前だけは、観測の鮮度を疑ってかからなければなりません。
awaseを書いていた当初、この問題を日本語入力エンジンに固有の癖だと考えていました。IMEという相手が、特に不誠実だからだと思っていました。しかし、Go2 Runtimeという別の相手を見たあとでは、そうは言えなくなりました。不誠実なのはIMEではなく、外の世界そのものでした。観測は届いた時点ですでに過去であり、それを疑わずに実行へ渡す設計だけが、繰り返し壊れていました。
外の世界を相手にするソフトウェアでは、観測したことと、いま実行してよいことを分けなければなりません。この一文は、IMEのために書いたものでした。しかし、この一文が指す境界は、IMEの外にもありました。
本書で追ってきたのは、一つのソフトウェアの千六百を超えるコミットそのものではなく、一つの区別が繰り返し必要になる場所の記録でした。その場所は、日本語入力エンジンの中にもあり、四本脚のロボットの中にもありました。次にどこで同じ形の空白に出会うとしても、確かめ方はもう分かっています。取消不能な境界の手前で、観測がいつのものかを、もう一度だけ問い直すことです。
この区別を保つことの難しさは、本書を書く過程そのものでも一度顔を出しました。やまぶきの内部構造を調べていた際、調査に当たったAIが一度、確かめてもいない実装を断定調で語ったことがあります。指摘を受けて実際にバイナリを解析し直すまで、その一文は根拠のない推測のままでした。観測していないことを断定してしまう失敗は、コードの中だけでなく、コードについて語る言葉の中にも、同じ形で現れます。
設計原則
観測の鮮度は、取得した瞬間に一度だけ確認して終わりにしてはいけません。取消不能な作用へ進む、最後の境界で再検証しなければなりません。
この原則は、観測から作用までの間に非同期の遅延が生じるすべての系に適用できます。単一のプロセス内で完結し、観測から作用までが同期的に進む処理には、通常あてはまりません。
実装の形は、鮮度を運ぶ専用の値(世代・epoch・タイムスタンプ)を型として持たせ、作用を実行する直前の境界でその値を照合することです。検査を通過した証拠と、いま実行してよい証拠は、別の型として扱います。片方だけを型で保証し、もう片方を暗黙の前提のまま残すと、空白はその境界に残ります。
この原則は、観測の陳腐化による誤作用は防ぎますが、他の要因による失敗までは保証しません。実行そのものが失敗する場合や、検証の基準自体が誤っている場合には、別の対策が必要です。再検証の仕組みを一度作ったあとも、それが実際にすべての実行経路を通っているかどうかは、別途確かめなければなりません。
付録
本付録は、本文で語った事件を再説明する場ではありません。再利用可能な設計パターン、章とADR・コミットの対応関係、用語の定義、参考文献を、検索して参照するための資料としてまとめます。
全体像: 最終的なレイヤー構成と、そこへ至る流れ
各章は一つの事件を追いますが、事件ごとの詳細に入る前に、最終的にawaseがどんな形に
落ち着いたかを俯瞰しておきます。以下の図は主経路(入力から出力まで)・観測と信念の層・
世代照合・アプリごとの適応層という4つのまとまりと、それぞれが互いにどこで作用を
差し止めているかを示しています。

矢印のラベルが、それぞれの層の役目を表しています。「アプリごとの適応層」は物理キーを
渡すかどうかとTSF側の準備確認を、「観測・信念層」と「世代照合」は4層reducerを通じて
「作用してよいか」の最終判断を、それぞれ主経路に対して差し込みます。主経路自体は
判断(Decision)と実行(Effect)を分離しただけの単純な形のままで、複雑さのほとんどは
この3つの周辺層に押し出されています。出力の先には、メモ帳・Word(Win32/UWP)、
Chrome・Edge、Windows Terminal・VS Codeという具体的なアプリ名を置き、その先で実際に
かな漢字変換を行うGJI(Google 日本語入力)・MS-IMEという2つの競合IMEへつながる様子も
示しています。フックからIME確定文字までの間、WH_KEYBOARD_LL・IMM32/TSF・
GetProcessIoCountersといったWindows APIのどこに触れているかも矢印に添えました。
最終形はいきなりこの4つのまとまりを持っていたわけではありません。以下は、同じ図法で
描いた3つの中間段階です。

最初の開発日には、これだけが動いていました。判定はengine一つで完結し、周辺の層は
まだ存在しません。

n-gramによる際どい判定の補正と、Decision/Effectモデル、アプリ種別ごとの出力切り替えが
加わりますが、依然として一本の経路の中だけで完結しています。

ここで初めて、主経路の外側に別の層が新設されます。「観測は信念を直接上書きしない」
というbeliefの発想(第5〜6章)と、「物理キーを渡すか渡さないか」を制御する適応層
(第7〜9章)です。主経路そのものは変わらず、複雑さが周辺へ押し出され始めているのが
分かります。
第4段階が、冒頭で見た最終形です。第10章で観測・信念層と適応層をあらためて4層reducerに
統合し、第11章で世代照合という型を追加することで、「作用してよいか」を最後に一箇所で
判断する経路が完成します。第12章はこの構造にも残る限界を認め、終章はGo2 Runtimeという
別の系で同じ空白を確認します。以降のパターンカタログ・ADR対応表・用語集は、この大きな
流れの中の個々の事件を検索するための詳細です。
パターンカタログ
本文全体から抽出した9個の設計パターンです。各パターンは「パターン名・文脈・問題・働く力・解決・結果・限界・関連する章とADR」の8項目で統一しています。
1. Observer never overrides desired(観測は信念を上書きしない)
- 文脈: 非同期のprobe・pollが到着し、既存の信念(belief)を更新しようとする場面です。
- 問題: 観測をそのまま状態へ反映すると、ユーザーの意図(intent)を、後から届いた古い観測が消してしまいます。
- 働く力: 観測の遅延・順序入れ替えは避けられません。一方でintentは、観測より優先して守られるべき情報です。
- 解決: Intent・Observation・Transition・Barrierの4層に分離し、Observationはbeliefを直接書き換えず、常にIntentとの照合を経由してから反映します。
- 結果: observationがintentを上書きして起きていた症状が解消しました。
- 限界: 4層分離自体は「いつ照合するか」を決めません。照合のタイミングそのものが、別の急所(パターン2)になり得ます。
- 関連する章・ADR: 第10章、ADR-032
2. Generation/epoch fencing(世代照合による足止め)
- 文脈: 非同期に完了するAPI呼び出し(IME apply等)の結果が、発行順とは異なる順序で返ってくる場面です。
- 問題: 古い呼び出しの結果が、新しい状態を上書きしてしまいます(stale write)。
- 働く力: 呼び出しの完了順序は制御できませんが、「どの世代に対する呼び出しか」は発行時に分かります。
- 解決: 単調増加する世代カウンタ(FocusEpoch)を発行時に刻み、適用時に現在の世代と照合してから適用します。
- 結果: observation起因のstale writeを構造的に防止しました。
- 限界: admit()通過後から実際の適用までの間に世代が変わるケース(TOCTOU、確認した瞬間と使う瞬間のずれ)は、世代照合だけでは防げません。
- 関連する章・ADR: 第11章、第12章、ADR-077
3. Own the channel, don't filter the noise(チャネルを断つ、ノイズは濾さない)
- 文脈: 合成イベントと物理イベントが区別できない入力経路です。
- 問題: フィルタを重ねても、新しいエッジケースが次々見つかり収束しません。
- 働く力: 合成と物理の判別は原理的に不可能な場合がありますが、経路そのものへのアクセスは制御できます。
- 解決: 判別を諦め、対象アプリに物理キーそのものを見せません(チャネルレベルでConsumeします)。
- 結果: 複数段階のフィルタ強化で解決しなかった問題が、チャネル遮断によって再発しなくなりました。
- 限界: チャネルを断てない構成(相手プロセスの内部ウィジェット構造など)では、別の観測手段が必要です。
- 関連する章・ADR: 第8章、ADR未確認(要補完)
4. Side-channel observability(副次観測)
- 文脈: 競合プロセスの内部状態を知る公式APIが存在しない場面です。
- 問題: DLLフック・COM傍受・IPC盗聴のいずれも失敗し、状態を直接観測できません。
- 働く力: OS標準のプロセスメトリクス(I/Oカウンタ等)は、公式APIとして公開されています。
- 解決: 必要な操作自体をプローブとして転用し、
GetProcessIoCounters()の差分から相手の処理内容を推測します。
- 結果: 競合IMEの活性化状態を、専用プローブを送らずに検出できるようになりました。
- 限界: この技法自体の一般的な限界は、2013年のアンチデバッグ手法の文脈でのみ間接的に確認されています。
- 関連する章・ADR: 第7章、ADR-048、ADR-062
5. Classify at the edge(端で分類する)
- 文脈: プラットフォーム層(TSF・IMM32。いずれもWindowsがIMEとのやり取りを仲介する仕組みで、IMM32が古くからのAPI、TSFがその後継)から届く生イベントは、種類も信頼度もまちまちです。
- 問題: コアロジックが生イベントを直接扱うと、分類ロジックがコア全体に散らばります。
- 働く力: 分類に必要な情報(由来・種別)は、プラットフォーム層でこそ最も豊富に手に入ります。
- 解決: プラットフォーム層で分類を完了させてから、コアロジックへ渡します(hourglassアーキテクチャ)。
- 結果: コア側は分類済みの型だけを扱えばよくなり、判定ロジックの重複が減りました。
- 限界: 分類基準を後から変える場合、プラットフォーム層と型定義の両方を変更する必要があります。
- 関連する章・ADR: 第10章
6. Confidence-tiered sensor fusion(確信度による観測の融合)
- 文脈: 複数のprobeが、同じ対象について矛盾する情報を返す場面です。
- 問題: 単純に「最後に届いた値を正とする」と、信頼度の低い観測が高い観測を覆してしまいます。
- 働く力: 各observationの由来(evidence)によって、信頼度は異なります。
- 解決: 観測源ごとに信頼度を重み付けし、投票的に1つのbeliefへ統合します。
- 結果: 確信度型の導入により、矛盾する観測がそのまま反映される事態を防ぎました。
- 限界: 重み付けの基準自体は経験則であり、新しい観測源が増えるたびに見直しが必要です。
- 関連する章・ADR: 第6章、ADR-043〜045
7. Snapshot at capture time, not at drain time(捕捉時にスナップショットする)
- 文脈: イベントが非同期キューに溜まり、ドレイン(取り出し)処理でまとめて処理される場面です。
- 問題: ドレイン時点の状態を基準にすると、イベント発生時と処理時の時間差が誤判定を生みます。
- 働く力: イベントの捕捉時点でこそ、正しい文脈(state)が確定しています。
- 解決: イベント捕捉時に状態をスナップショットし、ドレイン処理はそのスナップショットだけを見ます。
- 結果: 非同期ドレインに起因する時間差起因の誤判定を防ぎました。
- 限界: スナップショット自体が古くなる問題(admit後・適用前)は、このパターン単体では解決しません。
- 関連する章・ADR: 第10章、第11章
8. Shadow-mode migration(並走移行)
- 文脈: 新しい実装(shadow_model等)を、既存の実装と置き換える場面です。
- 問題: いきなり切り替えると、新実装の未知の欠陥が本番へ影響します。
- 働く力: 新旧を同時に動かして差分だけを見れば、切り替え前に欠陥を発見できます。
- 解決: 新旧実装を並走させ、差分ログで検証してから段階的に昇格させます(ADR-040)。
- 結果: shadow_modelをSSOT(Single Source of Truth、単一の真実源)へ昇格させる過程を、安全に行えました。
- 限界: 並走期間中は、二重のメンテナンスコストがかかります。
- 関連する章・ADR: 第10章、ADR-040
9. SSOTモチーフの反復
- 文脈: IME belief・GJI(Google Japanese Input、Google 日本語入力)warmth・
observation admissionという、一見別々の3つの問題領域です。
- 問題: それぞれ個別に「状態を一箇所に集約すれば直る」という仮説から出発し、一度は失敗しました。
- 働く力: 集約先を増やしても、陳腐化した観測が状態を汚染する問題そのものは消えません。
- 解決: 3領域とも最終的に「観測の受理条件を明示する」という同型の設計に収束しました。
- 結果: 独立に発見された同型パターンとして、他の領域でも再現し得る指針になりました。
- 限界: この反復は「集約すれば直る」という初期仮説が誤りだったことの傍証であり、正しい設計を自動的に導くものではありません。
- 関連する章・ADR: 第10章、第11章
10. Typed proof is not live proof(型は過去の証明であって現在の証明ではない)
- 文脈:
Go2Cmd<Checked>のようなphantom typestateが、値に「安全性チェックを通過した」という印を付与する場面です。
- 問題: 型が示すのは「過去のある時点でチェックを通過した」という事実であり、「いまも条件が真である」ことの証明ではありません。
- 働く力: 型システムは構築時点の条件しか強制できませんが、実行はその後の時間差を経て行われます。
- 解決: dispatch直前に、型が示す証明とは別に、現在の世代・鮮度を再検証する境界を設けます(パターン2と組み合わせて使います)。
- 結果: Go2 Runtime実地検証によって、この間隙が成熟した実プロジェクトでも構造的に残っていることを確認しました。
- 限界: 本書執筆時点では、この境界そのものを実装として持ち込む検証は完了していません。
- 関連する章・ADR: 終章
ADR・コミット対応表
章・事件・ADR番号・主要コミットの対応です。本文の再説明はせず、参照先として使う表です。
| 章 | 事件名 | 症状 | ADR番号 | 主要コミット | 不変条件 | 破れる条件 |
|---|
| 第10章 | IME belief/SSOTモデルの変遷 | observationがintentを上書き | ADR-032, 077 | d2e183f, f8dd8d4, 6baabf9, a4db93e, 604cf99 | 観測は意図を直接書き換えない/generation一致でのみ状態変更 | admit後・適用前のepoch遷移(TOCTOU) |
| 第4章 | Windows Terminal/TSFコールドスタートliteral化 | ローマ字キーストロークのリテラル漏れ | ADR-049 | 83d5707, 4249846, f426297, 9c80975, a3cce29 | 固定待機でなく副作用からreadiness推論 | 344ms超の長時間アイドル再初期化 |
| 第8章 | ImmCross/LINE偽VK_F3/F4 echo | 物理KANJIキー押下が偽トグル生成 | 要確認(未特定) | d99e3f1, 77ccf34, e890a26, f84c74b, 08b8661, 0e364ea | 対象アプリに物理IMEキーを見せない | ImmCrossProbe自体の信頼性問題(Qt子ウィジェット) |
| 第8章(補) | GJIキーバインド3世代交代 | F13/F14衝突 | ADR-034/057/067 | 7f8291f, 81df62c, cfbbd20, b271aee, 098c663 | idempotentな絶対セットキーを使う | MS-IMEはVK_KANJIが必要(ADR-063) |
| 第10章(補) | VK/ScanCode混同検知dylint | newtype導入後も取り違え継続 | ADR-012 | 2dd43f4 | vk.rs外でmagic hexを直接書かない | 未確認(現状lintで防御) |
| 第7章 | 捨て打ち機能(旧サクリファイシャル・ウォームアップ) | Chrome cold-start中のリテラル化 | ADR-048, 062 | 26bc0fe, d02ec44, 6c1732d, 22c3905 | 必要操作自体をプローブに転用、Chrome+GJIのみ適用 | vim等ターミナルアプリとのVK_A衝突(他アプリはVK_IME_OFF→ON方式へ離脱済み) |
| 第9章 | StepCoro統一 | 増殖したFSM群(5種類)の複雑化 | ADR-053 | e548e63, ccd4711, f01d401, b077c3d, 2c756dd, d1d6d17 | 一回限りの手続きはStepCoro、長期生存・外部問い合わせ対象はFSMとして残す | ウォームパスのLiteralDetectFsmはコルーチン化されず残存(意図的) |
注記です。第8章のコミットハッシュとADR番号の一部は、既存調査からの転記であり、リポジトリ側での再確認は未実施です。
拡張用語集
src/toc.mdの用語集をもとに、Go2 Runtime実地検証で見つかった対応語を追記しています。
| 用語 | 定義 | Go2 Runtime対応語 |
|---|
| awase (awase) | 本書で描くWindows用日本語入力エンジン(NICOLA親指シフト方式) | — |
| NICOLA / 親指シフト | 親指キーとの同時打鍵タイミングで仮名を確定する日本語入力方式 | — |
| shadow state | OSが正直に教えてくれないIME状態を、自前で推測・保持する仕組み | WorldState(immutableな世界モデル) |
| belief(信念) | 観測から導出した「いまのIME状態はこうだろう」という推定値 | BeliefView<T>(旧称BeliefSnapshot<S,B>) |
| Observation(観測) | probe・pollから届く生の情報。beliefを直接上書きしない | StateUpdate(S層が受け取る生観測) |
| FocusEpoch | フォーカス変更等を境に単調増加するカウンタ。観測の鮮度を判定する基準 | TickId(tick単調カウンタ。ただしepoch照合には未使用) |
| AcceptedObservation | 世代(epoch)照合(admit)を通らないと構築できない、型で保証された「新鮮な観測」 | 現状Go2に相当機構なし(Go2Cmd<Checked>は過去の証明のみ) |
| TOCTOU急所 | admitした時点では新鮮でも、実際に適用する時点では陳腐化している間隙 | Executor::execute直前のガードがwatchdogのみである間隙(ADR-R009) |
| 捨て打ち機能 | 必要な操作自体をプローブとして転用し、I/Oカウンタ差分で副次観測する技法 | 直接対応なし(SimIo::injectは素朴な注入のみ) |
| Go2 Runtime | 著者が別に設計しているロボット(Unitree Go2)向けアプリケーションランタイム | — |
| Observed<T> | Go2 Runtimeの観測品質型。Fresh・Stale・Unknown・Brokenの4値を持つ | awaseのshadow state・beliefに相当 |
| BeliefQuality | 観測の信頼度(confidence)と裏付け種別(evidence)を保持する型。鮮度は持たない(INV-OB1) | awaseの確信度型に相当 |
| ControlLease / fencing_token | 複数オペレータ間で、古い命令が新しい命令を誤って上書きしないためのリーダー選出の仕組み(ADR-P005) | awaseのFocusEpochとは別問題(対比事例) |
| Field<T> | beliefを介さない素の観測値(odom等)にも、鮮度・品質を必須で添付するラッパ型 | shadow stateの「何でもbelief化する」思想と相似 |
参考文献リスト
| 文献 | 書誌情報 | 備考 |
|---|
| Kleppmann, フェンシングトークン論 | Martin Kleppmann, "How to do distributed locking", 2016-02-08, https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html | fencing tokenの標準的な説明・Redlock批判で知られるブログ記事です。 |
| Chubby (Burrows, OSDI 2006) | Mike Burrows, "The Chubby lock service for loosely-coupled distributed systems", 7th USENIX OSDI '06, 2006-11. https://www.usenix.org/conference/osdi-06/presentation/chubby-lock-service-loosely-coupled-distributed-systems | PDF直リンクは要検索です。書誌情報としては確認済みです。 |
| Firefox OS/Gaia bug 1110030 | Bugzilla@Mozilla, Bug 1110030, "Routing hardware key events to keyboard app when an input field is focusing", https://bugzilla.mozilla.org/show_bug.cgi?id=1110030 | IsSynthesizedByTIPフラグへの言及箇所は要検索です(バグ番号・タイトルのみ確認済み)。 |
| NIOSH Hierarchy of Controls | CDC/NIOSH, "Hierarchy of Controls", https://www.cdc.gov/niosh/hierarchy-of-controls/about/ | elimination・substitution・engineering・administrative・PPEの5段階を示す公式頁です。 |
| Yaron Minsky, "Make illegal states unrepresentable" | 原典記事URLは要検索です(OCamlコミュニティで広まった言い回し)。 | 要検索 |
| "Typestate via Revocable Capabilities" (arXiv 2510.08889) | arXiv番号のみ確認済みで、URLは要検索です。 | 要検索 |
Kubernetes resourceVersion | Kubernetes公式ドキュメント, "API Concepts: Efficient detection of changes", https://kubernetes.io/docs/reference/using-api/api-concepts/ | オブジェクトの世代を表す文字列で、楽観的排他制御に使われます。 |
| Raftコンセンサスアルゴリズムのterm番号 | Diego Ongaro, John Ousterhout, "In Search of an Understandable Consensus Algorithm", USENIX ATC 2014 | 第11章で触れた「任期」番号の出典です。 |
| Gaffer On Games | Glenn Fiedler, "Gaffer On Games"(ネットコード分野で広く参照される個人技術ブログ), https://gafferongames.com/ | URLはトップページのみ確認、個別記事の直リンクは要検索です。 |
| GGPO | GGPO開発チーム, "GGPO Rollback Networking SDK", https://github.com/pond3r/ggpo | ロールバック方式ネットコードの代表的な実装です。 |
Bevy Tick | Bevy公式ドキュメント, bevy_ecs::component::Tick, https://docs.rs/bevy_ecs/latest/bevy_ecs/component/struct.Tick.html | Rust製ゲームエンジンBevyのECSが持つ、フレームごとに増える世代カウンタです。 |
crossbeam-epoch | crossbeam-rsプロジェクト, https://docs.rs/crossbeam-epoch/ | 「epoch」という語は共通ですが、解いているのはメモリ回収問題であり、第11章の観測の陳腐化とは別問題です(false friend)。 |
slotmap / generational-arena | それぞれのcrates.ioページ、https://docs.rs/slotmap/ 、https://docs.rs/generational-arena/ | 世代付きインデックスを持ちますが、値の構築自体を型で制限してはいません。 |
GhostCell / generativity | Joshua Yanovski et al., "GhostCell: Separating Permissions from Data in Rust"(ICFP 2021)、generativity crateドキュメント | ブランド型・phantomライフタイムで不正な構築を防ぐ技法ですが、世代カウンタとの組み合わせ例は確認できませんでした。 |
| Peter Ferrie, アンチデバッグ手法集 | Peter Ferrie, "The Ultimate Anti-Debugging Reference", 2011年初出・以降改訂 | I/Oカウンタ差分によるプロセス挙動の推測が、2013年前後の解説記事に同様の手法として登場します。原典URLは要検索です。 |
| F5/NGINXの受動的ヘルスチェック | F5公式ドキュメント「Passive Health Monitoring」、NGINX公式ドキュメント「passive health checks」 | 専用の確認要求を送らず、既存の通信に相乗りして相手の状態を推測する手法です。 |
| チョード式キーボードの特許(US4,680,572) | United States Patent 4,680,572 (1987年登録) | 複数キーの組み合わせで文字を確定させる入力方式の古典的な特許です。 |
| QMKファームウェアのtap-hold機能 | QMK公式ドキュメント, "Tap-Hold Configuration Options", https://docs.qmk.fm/tap_hold | キーを押す長さで異なる動作を割り当てる、自作キーボード向けファームウェアの機能です。 |
| Rodney Brooks, Subsumption Architecture | Rodney A. Brooks, "A Robust Layered Control System for a Mobile Robot", IEEE Journal of Robotics and Automation, 1986 | 知覚・計画・行動という順序そのものに異議を唱えたロボティクスの古典的論文です。 |
| Joint Detection and Estimation理論 | 信号処理分野における、検出(detection)と推定(estimation)を同時に扱う理論の総称。個別の代表論文は要検索です。 | 逐次処理(先に検出、後で推定)の劣位性を形式的に扱う理論分野です。 |
| Gary Bernhardt, "Functional Core, Imperative Shell" | Gary Bernhardt, "Boundaries"(講演、2012年、Destroy All Software) | 判断を行う純粋な核と、副作用を実行する薄い外殻を分ける設計思想の呼び名として広まりました。講演URLは要検索です。 |
| Android ANR(Application Not Responding) | Android公式ドキュメント, "Keep your app responsive", https://developer.android.com/topic/performance/vitals/anr | メインスレッドが一定時間応答しないアプリを検知する仕組みです。URLは要検索です。 |
| Kubernetes readiness/livenessプローブ | Kubernetes公式ドキュメント, "Configure Liveness, Readiness and Startup Probes", https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ | 固定時間ではなく実際の応答確認によって、通信を回してよいかを判断する仕組みです。 |
| TCP再送タイマーとKarnのアルゴリズム | Phil Karn, Craig Partridge, "Improving Round-Trip Time Estimates in Reliable Transport Protocols", ACM SIGCOMM 1987 | 実測した往復時間をもとに再送までの待ち時間を更新し続ける手法です。 |
| Redux | Redux公式ドキュメント, https://redux.js.org/ | 一回限りの処理と、アプリ全体が持ち続ける状態を意図的に分離するフロントエンド向け状態管理ライブラリです。 |
| JavaScript/Python/Rustのasync/await | 各言語公式ドキュメント(ECMAScript async functions、PEP 492、Rust async book) | コンパイラが一直線に書かれた手続きを、中断・再開可能な状態機械へ変換する仕組みです。個別URLは要検索です。 |
| フライバイワイヤとフライトエンベロープ保護 | 例: Airbus公式資料「Fly-by-wire」解説等 | 操縦桿の意図・センサーの観測・保護則の最終判断を分離する航空機の設計です。個別の一次資料URLは要検索です。 |
| CWE-367 (Time-of-check Time-of-use Race Condition) | MITRE, "CWE-367", https://cwe.mitre.org/data/definitions/367.html | 確認時点と使用時点のずれによる脆弱性の分類項目です。 |
未確認・要確認事項
- 第8章の一部コミットハッシュは、リポジトリでの再確認が未実施です。
- 第8章(ImmCross/LINE偽イベント)に対応するADR番号は未特定です。
- 参考文献のうち「要検索」と記した項目は、出版前に原典を直接確認してください。
- 第11章・第7章・第8章・第3章に追加した関連研究(フェンシングトークン、ゲームnetcode、
Rustの世代付き型、アンチデバッグ手法、受動的ヘルスチェック、労働安全の階層モデル、
チョードキーボード特許、Subsumption Architecture等)の書誌情報は、本セッションで
Web検索を伴わずに記憶とmaster-research-logの記述から記載したものです。URL・年号・
巻号等は出版前に一つずつ裏取りしてください。
- 第1・2・4・9・10・12章に追加した関連研究(ブラウザのイベントループ/Android ANR、
RAIIの他言語比較、Kubernetes readiness/livenessプローブ、TCP再送のKarnのアルゴリズム、
Redux、async/await、フライバイワイヤのフライトエンベロープ保護、CWE-367/TOCTOU等)も
同様にWeb検索を伴わず記憶から記載したものです。出版前に一つずつ裏取りしてください。