はじめに:1日で動いた。しかし、それで終わりではなかった
キーを正しい順序で送りました。IMEの状態も、直前に確認していました。それでも、最初の一文字だけが変換されず、ローマ字のまま入力欄に残りました。
最初に疑ったのは、キー配列を変換するロジックそのものでした。しかし、ログを追っても、変換表にもタイミング判定にも誤りは見つかりませんでした。おかしいのはロジックの中身ではなく、ロジックが前提にしていた「いまIMEはこういう状態のはずだ」という思い込みの方でした。
さらに厄介だったのは、同じ手順を繰り返しても、必ず再現するわけではなかったことです。ある回では正しく変換され、次の回では最初の一文字だけが化けます。原因が入力の順序になければ、残るのは「そのとき何が起きていたかを、こちらが正しく把握できていなかった」という可能性でした。この種の不具合は一度きりではなく、似た症状が違う原因で何度も形を変えて現れました。
これは、Windows用の日本語入力エンジン「awase」(NICOLA親指シフト方式)を開発しているときに起きた出来事です。awaseは著者が個人で、長い年月をかけて作り続けているソフトウェアです。最初の動くプロトタイプは、開発を始めたその日のうちに完成しました。記録を見る限り、この初日の実装を担ったのはAIモデルのClaude Opus 4.6でした。翌日未明からは早くも、動いた経路の内部で型や資源の境界を引き直す作業が始まっています。しかし、その後の道のりは、1日で動いたことからは想像できないほど長いものになりました。
本書は、その長い道のりで繰り返し出会った「一見同じに見える不具合」を、症状・仮説・実験・発見・設計というひとまとまりの物語として記録したものです。理想的な設計を最初から思いついていた話ではありません。壊れ方を見て、そのつど設計を発見し直してきた記録です。
変換器ではなく、相手のいるソフトウェアだった
awaseは、見た目にはキー配列を別のキー配列へ変換するだけの、単純なフィルタのように見えます。しかし実際には、awase自身が制御できない相手を常に観測し続けるソフトウェアでした。
相手とは、Windowsが管理するIMEの状態であり、フォーカスがどのウィンドウにあるかであり、他のアプリケーションが送ってくる非同期のイベント(こちらの都合を待たずに届く通知)です。これらはどれも、問い合わせても正直に教えてくれるとは限らず、古い状態を返したり、一度返した答えを覆したりしました。
具体的にはIMEの開閉状態、変換モードの向き、フォーカス中のウィンドウといった情報です。awaseはこれらを直接読み出せず、遅れて届いたり順序が入れ替わったりする断片的な信号から推測するしかありませんでした。
Windowsは、IMEとやり取りするための仕組みを公式に二つ用意しています。古くからあるIMM32は、IMEのON/OFF状態や変換モードをアプリケーション側から尋ねたり指示したりするためのAPI(プログラム同士が情報をやり取りする窓口)群です。より新しいTSFは、IMEだけでなく手書き入力や音声入力も含め、アプリケーションと入力サービスの間を仲介する仕組みです。アプリによってどちらを使うかは異なりますが、awaseにとっては事情は変わりませんでした。仕組みを通じて値は返ってくるものの、それが「いま」か「少し前」かは、仕組み自体からは分かりませんでした。
この構造は、日本語入力という領域に固有のものではありません。協調してくれない外部プロセスを、一方向のキー送信と断片的な観測だけで制御しようとする場面は、GUIの自動化ツールにも、分散システムのクライアントにも、外界をセンサーで認識するロボットにも共通しています。awaseの不具合の多くも、日本語入力の癖ではなくこの共通構造から生まれていました。
この経験から、本書を貫く一つの問いが生まれました。外の世界を相手にするソフトウェアを設計するとは、どこまで「同じ」とみなしてよいかを決める仕事だ、という問いです。
同じという扱いが崩れる境目は、大きく二つありました。一つは時間をまたぐ境目で、遅れて届いた証拠をいま実行中の処理のものと見なしてよいかが争点でした。もう一つは空間をまたぐ境目で、IMM32を使うアプリとTSFを使うアプリのように性質の異なる複数の相手を、同じ一つのロジックで扱ってよいかが争点でした。中には、一度返した答えをあとから読み戻して確かめる手段を持たない相手もいました。そうした相手については、「同じ」とみなしてよい根拠が比較のしようがないという意味で、最初から存在しませんでした。
この区別を必要とした場面は、awaseの内側だけにとどまりませんでした。何を疑い、いつ疑いを解くかという同じ判断は、コードを書く過程そのものでも、開発の終盤に問われています。本書の終盤(第VII部)では、この境目にも触れます。
本書の大半では、まず時間をまたぐ境目を扱います。
外の世界を相手にするソフトウェアの設計とは、どこまで「同じ」とみなしてよいかを
決める仕事である。空間をまたいでは、性質の異なる相手を一つのロジックで扱ってよいか。
時間をまたいでは、遅れて届いた証拠を、いま実行中の試行のものと見なしてよいか。
そして、相手を読み戻せないなら、同じとみなす根拠は最初から存在しない。
そしてこの区別は、コードの中だけでなく、コードを書く過程そのものにも要る。
このうち、時間をまたぐ境目については、本書はすでに一つの答えを出しています。観測したことと、いま実行してよいことを分けなければならない、という考え方です。
観測は、常に少し過去のものです。実行は、常にいまこの瞬間に対して行われます。この二つを同じ値として扱ったとき、awaseは何度も壊れました。型として分けたときにだけ、壊れ方は減りました。しかし、この型分けだけでは説明のつかない壊れ方が、後になってさらに見つかりました。本書の後半では、もう一つの境目——空間をまたぐ境目——もあわせて追いかけます。
観測と実行を分けるまでの記録
本書に登場する事件や数値は、記憶だけを頼りに書き起こしたものではありません。コミット・ADR(設計判断の記録)・当時のコードそのものを一次資料として、そこから再構成しています。
調査の過程で、著者自身の当初の記憶と、記録に残っていた事実がずれている箇所もいくつか見つかりました。そうした箇所は、本文中で「当時のADRにはこう書かれていた」「現在振り返るとこう見える」というように、記録に基づく記述と著者の解釈とを書き分けています。
読者がこの本から得られるのは、完成した設計の説明書ではありません。ある不具合がなぜ起きたのか、最初の仮説がなぜ外れたのか、次にどんな実験をしたのか、という試行錯誤の順序そのものです。設計は、その順序の先にしか現れませんでした。
日本語入力エンジンの内部実装に詳しくなくても、本書は読み進められるように書いています。必要な前提知識は、話が必要とする箇所でそのつど説明します。読者に求めているのは、外部システムを相手にするソフトウェアを書いた経験、あるいはこれから書く関心の方です。
本書の終盤(第VII部、第13章から第15章)では、この境目をコードを書く過程そのものに向けた経緯を扱い、終章では著者が別に取り組んでいるロボット向けランタイムの設計にも軽く触れます。
まずは、最初の一文字がローマ字のまま残った、あの日の話から始めます。
第1章:なぜ親指シフトを、自分の手で作ったのか

親指シフトでは、文字キーと親指キーを、ピアノの和音のように合わせて押します。片方の手で文字キーを押しながら、もう片方の手で親指キーをそっと添えると、単独で押したときとは違う文字が生まれます。決めるのはキーの位置ではありません。二つのキーが「間に合っているかどうか」という、指の間の短い時間差です。
この時間差を正しく扱えるかどうかで、指を動かした結果が、意図した文字になるか、まるで違う文字になるかが決まります。既製のキーボードソフトを使っていたときに苦しめられたのは、まさにこの一点でした。狙った通りに合わせて押しているつもりでも、ソフトの側が正しく読み取ってくれない場面が、繰り返しありました。
この時間差を扱うために、2026年3月28日、私は自分の手でキーボードエミュレータを書き始めました。その日のうちに、変換の最小経路はすでに動いていました。ただし、動いたことは、正しく動くことをまだ何も意味していませんでした。
二つのキーが「間に合う」かどうかで、文字が変わる
一般的なキーマッピングでは、キーと文字は一対一で対応します。Aキーを押せばAが出る、という表さえあれば十分です。ShiftキーやCtrlキーとの組み合わせも、押している間だけ状態が変わるという意味では、やはり静的な表の延長にあります。
しかしNICOLA方式の親指シフトでは、文字キーと親指キーのあいだの時間差そのものが変換結果を左右します。近いタイミングで押せば同時打鍵、間隔が開けば単独打鍵として扱われます。押しているかどうかという二値の状態だけでは、この判定を表現できません。
ここでいうキーボードフックとは、OSが発生させるキー入力を、本来の送り先に届く前に横取りし、内容を確認・加工できる仕組みを指します。フックが受け取れるのは、個々のキーの押下・離上イベントとそのタイムスタンプだけであり、そこから「二つのイベントは同時打鍵と呼べる関係にあるか」を組み立てる必要があります。この判定を具体的にどう組んだかは後の章で扱いますが、ここではまず、親指シフトが「配列表」の話ではなく「タイミング」の話だという点だけを押さえておきます。
単純なキーリマッパーの多くは、キーを別のキーへ静的に置き換える前提で作られています。その前提に同時打鍵の判定を後から足すのは、機能追加というより設計の作り直しに近い作業でした。ゼロから実装を選んだ理由をこの一点だけに帰する記録は残っていませんが、現在振り返ると、この構造的な違いは無視できない理由の一つだったと思います。
やまぶきRは、なぜ定期的に止まったのか
Windows向けの親指シフトエミュレータは、当時すでにいくつも存在していました。設計文書の冒頭には、次のような目的が書かれています。
Windows 上で動作するキーボード配列エミュレータを Rust で開発する。既存の「やまぶき」「DvorakJ」と同等の機能を持ち、NICOLA(親指シフト)を含む任意のキー配列をエミュレートできる常駐型ツールを目指す。
参考にしたプロジェクトも同じ文書に列挙されています。kanataからはLLHOOKバックエンドの実装パターンとスキャンコードベースの入出力を、やまぶきからはNICOLA配列の挙動仕様を、DvorakJからは機能範囲を、それぞれ参考にしたとされています。特に、kanataがplatform-known-issues.adocにまとめていたWindowsのLLHOOK固有の既知問題は、後述するガード設計に直接反映されたとあります。
自作を選んだ理由は、記録より先に、私自身の使用経験にありました。もともと使っていたのはやまぶきRでした。ところが定期的にプロセスが止まり、そのたびに手動で再起動する必要がありました。Zoomなど一部のアプリとは相性が悪く、うまく動かない場面もありました(この一文はADRのような一次資料ではなく、私自身の記憶に基づく記述です)。そしてWindows向けの親指シフトソフトで、開発が継続されているものが、そもそも見当たりませんでした。使い続けるための選択肢は、実質的に自分で直す以外にありませんでした。
開発が継続されているものが見当たらないという状況は、不具合が見つかっても直してもらえる見込みがないことを意味していました。自分で直す以外に選択肢がないなら、ソースコードごと手元に置いておく以外に、長く使い続ける方法はありませんでした。
なぜ止まりやすかったのか、当時は理由が分かっていませんでした。やまぶきはソースコードが公開されていないクローズドソースのソフトウェアです。本書の執筆にあたり、配布されている実行ファイルが呼び出す外部関数の一覧(インポートテーブル)と、内部の文字列を解析したところ、理由の一端が見えてきました。旧版のやまぶき4は、キーボードフックのコールバックの中でIME操作のAPI(ImmSetConversionStatus)を直接呼び出し、同時打鍵かどうかの判定にはSleepを使って、フックのスレッドそのものを一時停止させていました。やまぶきRでは、IME状態を直接書き換える呼び出しは姿を消していましたが、タイミング判定のためのSleepだけは、フックの中に残ったままでした。
フックコールバックは、短時間で処理を返さなければ、Windowsによって強制的に解除されます。フックは玄関先で来客の用件を一瞬だけ確認する係のようなもので、そこで長く引き止めれば、OSはその係ごと持ち場から外してしまいます。Sleepでスレッドを止める処理は、この「短時間で返す」という前提に反していました。この解析はバイナリの構造から読み取った推定であり、断定はできません。それでも、旧版から新版にかけて問題の一部にだけ手が入り、フックの中で時間のかかる処理をするという前提そのものには触れられていなかった、という輪郭は見えてきます。問題の一部にだけ気づいて、根本には触れない。これは、やまぶきに限らず、ありがちな失敗の形です。ソースコードを読めない相手であっても、外部から呼び出す関数の並びだけで、設計判断の輪郭がある程度は見えてくるという発見でもありました。
この問題は、awase固有のものでも、Windowsに固有のものでもありません。重い処理をしているタブを開いたまま他の操作をすると、ブラウザ全体が「応答なし」になって固まることがあります。多くのブラウザは画面の描画とクリックへの反応を一つのスレッドで順番にさばいており、どこか一箇所が時間をかけすぎると、他のすべての反応が止まります。スマートフォンのアプリでも同じ理由で警告が出ることがあり、Androidにはこれを検知する仕組み(ANR、Application Not Responding)が組み込まれています。決まった時間内に必ず制御を返すべき場所に、重い処理を持ち込んではいけないという制約です。
この個人的な経験は、後日のADR(Architecture Decision Record、設計判断の記録)にも、より広い形で裏付けられています。2026年3月30日夜のコミット2835bf6には、他の親指シフトエミュレータで報告されていた問題が整理されています。
| 問題 | 他ソフトの状況 | awaseの対応 |
|---|
| フック消失 | 紅皿でPC高負荷時にキーボードがフリーズすると報告 | ハートビート監視、10秒無応答で警告 |
| 管理者権限プロセス | 紅皿v0.1.3で管理者昇格オプションが追加 | トレイに「管理者として再起動」を用意 |
| キーボードレイアウト変更 | Windows Updateで106→101配列に変わる事例が多発 | 起動時に確認し、変更イベントも監視 |
| 画面ロック/セッション切替 | やまぶきRでスリープ復帰後に動作しなくなる報告 | 電源・セッション切替で状態を丸ごと引き直す |
| 修飾キーのスタック残り | 調査した全エミュレータに共通する問題 | フォーカス変更時に押下状態を同期し直す |
| カタカナ/ひらがなキー | ロック型でキーアップが来ず、親指キーに使えない | IMEガードとして扱えるキー数に上限を設ける |
これは他ソフトの不具合を非難するための一覧ではありません。常駐して全キー入力を横取りするツールが、どこで壊れやすいかを先に洗い出した、失敗のカタログです。プロジェクト開始からわずか2日というタイミングで、同時打鍵の判定そのものより先にこの棚卸しが行われている点は見落とせません。やまぶきRで実際に手を止められた経験があったからこそ、この優先順位は自然に決まっていました。欲しかったのは変換の巧妙さより、日常的に使うツールが些細な不具合で入力不能に陥らないという安心感でした。
このツールには「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.
「合わせ」は、複数のキーを合わせて押すという動作そのものを指す名前です。技術的な仕組みの名前ではなく、指の動きの名前が選ばれている点は、このツールが最初から使う側の体感を軸に語られていたことを示しています。
この名前には、後からもう一つの理由が加わりました。プロジェクトの公式サイトがリニューアルされた際、「名前の由来」という説明が追加されています。「awase」をQWERTYキーボードで打つと、五文字は外側から内側へ流れるように打鍵できます。同時打鍵を「合わせる」という意味に加えて、指が美しく動く並びでもあった、という遊び心が、数か月たってから明かされたことになります。
最初に作ろうとした範囲は、NICOLA専用のツールではありませんでした。同時打鍵の判定は、前節で引用した設計文書が掲げる常駐型ツールの中でも、特に難しい一機能という扱いにすぎませんでした。同時打鍵の判定をどう表現するか、壊れたときに何を優先するか、といった具体的な設計は、まだ何も決まっていませんでした。実際に手を動かし始めてから、最初の壁にぶつかるまでにかかった時間は、ごくわずかでした。
1日で、信号を出すだけのフックができていた
開発を始めた2026年3月28日、コミットは21件記録されています。最初のコミット3444942から、実テキストを流し込むシナリオテストの4bfd034まで、その日のうちに一式がそろいました。
| 層 | 主なモジュール |
|---|
| 捕捉 | hook(WH_KEYBOARD_LLフック)、RawKeyEvent/KeyAction(コア型)、scanmap |
| 判定 | timed-fsm、engine(NICOLA同時打鍵状態機械)、kana_table、ngram |
| 出力 | output(SendInputキー注入)、ime(TSF+IMM32検出)、platform traits、tray、main |
これらは、思いつくままに書き足された名前の羅列ではありません。一つ一つが、入力から出力までの経路のどこかを担う部品です。捕捉するhook、判定を行うtimed-fsmとengine、出力するoutput・ime・trayという三つの層が、この21コミットの中に最初から並んでいました。
判定を行うengineはOSに依存しません。KeyboardHook・KeySender・ImeDetectorという抽象境界がその外側にあり、実際のWin32呼び出しはさらに外側のWindows実装だけに閉じていました。判定ロジックとOSとのやり取りを、最初から別の場所に置いていたということです。
キーボードフックには、前節で触れた時間の制約がありました。フックの中で時間のかかる処理を行うこと自体が、そもそも許されていません。hookモジュールの役目は、この制約から逆算して一つに絞られていました。押下・離上のイベントを拾い、タイムスタンプを付けて、次の層へ渡すだけです。同時打鍵かどうかの判定も、IME状態の操作も、フックコールバックの外側で行われます。フックは判断する場所ではなく、信号を出すだけの場所として設計されていました。
| 項目 | やまぶき4 | やまぶきR | awase |
|---|
| フックが処理をブロックするか | する(Sleep) | する(Sleep) | しない(即return) |
| IME操作の場所 | フック内(同期) | フック内(同期) | メッセージループ内(非同期) |
| フックの役目 | 判定と操作の両方 | 判定のみ | 信号を出すだけ |
この設計判断が的外れではなかったことは、前節で触れたバイナリ解析によって後になって裏付けられました。従来ツールがフック内でブロッキングAPIを呼ぶ設計だったのに対し、awaseはフックを「信号を出して即returnするだけ」にし、IME操作をメッセージループ側へ委ねることで、再入・デッドロック・キー消失を構造的に排除しようとしました。
正しく変換された文字が初めて出力に現れた瞬間、動いたのはこの三段階でした。キーボードフックが拾った押下イベントはengineに渡り、判定結果はKeySenderを通じてWindowsに渡りました。その一式には判定ロジックだけでなく、SendInputによるキー注入・TSFとIMM32を組み合わせたIME状態検出・システムトレイアイコン・シナリオテストまで含まれていました。単なる思いつきの試作ではなく、すでに実運用を意識した骨格でした。
キーを受け取った後、どう手放すかにも選択肢がありました。一般的なキーボードフックには二つの動作方式があります。一つは、関係のあるキーだけを横取りし、それ以外はそのまま素通しするフィルター方式です。もう一つは、すべてのキーをいったん飲み込み、判定を終えてから自分で送り直すリレー方式です。二つの方式で何が変わるかは、図で見たほうが早いので、先に示します。

awaseが採ったのは後者でした。フィルター方式は他のフックソフトウェアとの実行順序次第で競合しうる一方、リレー方式はすべてのキーを一度FIFOキューに通すため、順序の保証が経路全体で一貫します。AutoHotKeyのような他のキーリマッパーと併用しても入力が届き続けるのは、この設計の副産物です。
同じ日のコミットには、やまぶき互換のレイアウトファイルを読み込むyab parserや、文字の出現頻度に応じて同時打鍵の許容幅を動かすngramモジュールも含まれていました。決め打ちの閾値一つだけで済ませず、人や文章によって打鍵の間隔が違うことを最初から前提にしていたということです。
SendInputで実際に何を送るかにも、選択肢がありました。NICOLA判定の結果は、ひらがな一文字として確定します。この一文字を下流のアプリケーションへ渡す方法には、大きく三つの案がありました。三つの案のうち二つが同じ弱点を抱えていたことを、次の図にまとめます。

IMEをJISかな入力モードに切り替えて同じVKキーコードを送る案、ひらがなをローマ字へ逆変換してローマ字入力モードのままVKキーコードを送る案、そしてIMEを経由せず確定済みのひらがな文字そのものをUnicode文字として直接送り込む案です。採用されたのは二つ目でした。理由は、残る二つの案が同じ弱点を抱えていたことにあります。数字や記号の入力ではUnicode文字を送らざるを得ず、Unicode文字はIMEの変換候補に入らず確定済みの生の文字として扱われてしまい、Chromeとの相性問題がありました。ローマ字のVKキーコードであれば、ひらがなも数字も記号も同じ経路で送れ、この問題を構造的に避けられます。
ただしリレー方式には、それ自体が生む問題もありました。送り直したキーを、awase自身のフックがもう一度受け取ってしまえば、無限ループになります。この事故を避けるため、送り直すキーにはdwExtraInfoというWindowsが用意するフィールドに専用の目印を付け、フックの側で「これは自分が送ったものだ」と判別できるようにしました。この仕組みを持つhookモジュール(hook.rs)が最初に追加されたコミット39ed0c6は、timed-fsm追加コミット6776e94(21時17分09秒)の1秒後、2026年3月28日21時17分10秒でした。n-gramモデルの土台となるコミット6990a7dも、まったく同じ時刻に記録されています。信号を出すだけに絞ったフックの設計と、その信号を安全に往復させる仕組みと、統計的な判定補正の土台は、同じ瞬間に一つの塊として生まれていたことになります。timed-fsmという判定の土台をどう活かしたかは、第2章で扱います。
「動いた」翌日から、境界を引き直す作業が始まった
文字が正しく入ったことは、うれしい出来事でした。しかし、それは経路が一度、期待通りの入力に対して動いたという事実でしかありません。動いたコードは、正しく動く条件を教えてくれません。教えてくれるのは、次にどこが壊れそうか、という手がかりだけです。
開発を始めてから1日後、2日後、3日後にかけて、プロトタイプには次々と構造が足されていきました。新しい機能を足すためではなく、すでにある経路の内部の境界を引き直すための変更でした。
一つ目は、型を混ぜないための名前です。当時のADRには、仮想キーコード(VK)とスキャンコードが、どちらもu16として扱われ、関数シグネチャからは引数がVKコードなのかタイマーIDなのか区別できなかった、と整理されています。3月29日のコミットd86425bで適用が始まり、翌30日の314e3afで、VkCodeとScanCodeというnewtype(同じ基本型でも意味の違う値を、別の型として区別する手法)が全面適用されました。以後、VKコードとスキャンコードの取り違えは、実行時ではなくコンパイル時に検出されるようになりました。バグを直したのではなく、そのバグが起こり得る余地そのものを、型の側から塞いだという違いがあります。
二つ目は、後始末を型に任せることです。Win32リソース(キーボードフック、ホットキー、タイマー、トレイアイコン、WinEventフック)は、それまで手動でcleanup()の中で解放していました。呼び忘れの懸念に加え、パニック時にクリーンアップが保証されないという懸念もありました。newtypeの全面適用と同じ3月30日、コミットabd43d8で資源ごとにガード型が導入されます。これは一般にRAII(Resource Acquisition Is Initialization)と呼ばれる手法です。
| ガード | 対象 | Dropで呼ぶ処理 |
|---|
| HookGuard | キーボードフック | UnhookWindowsHookEx |
| HotKeyGuard | ホットキー | UnregisterHotKey |
| TimerGuard | タイマー | KillTimer |
| WinEventHookGuard | WinEventフック | UnhookWinEvent |
| SystemTray(Drop実装) | トレイアイコン | Shell_NotifyIconW(NIM_DELETE) |
値が作られた瞬間に資源を確保し、値が不要になった瞬間、破棄(Drop)にあわせて資源を自動的に解放します。解放を呼び忘れるという判断を、人間の注意力ではなく、値の寿命そのものに委ねたということです。この考え方自体はRustやC++に閉じたものではなく、Pythonのwith文も、ブロックを抜ける瞬間に確保した資源を自動的に閉じる同じ約束を実現しています。
三つ目は、判断と実行を分けることです。engineの内部はもともと副作用を宣言的な値として返す作りでしたが、その外側(IMEガード、特殊キー判定、IME制御)は、Win32 APIを直接呼び出す命令的なスタイルのままでした。同じ3月30日、コミット6543603で外側もengineに合わせて書き直され、2332c94でADRとして文書化されます。
Engine::on_input(event, ctx) → Decision (純粋な判断のみ)
AppState::execute_decision(decision) (副作用の実行はここだけ)
判断を返す層と、副作用を実行する層を、それぞれ一つに絞ったということです。この整理だけでmain.rsとAppStateの実装は300行以上短くなったとADRに記録されています。ここでいう「判断」は、SendKeys(キーを送る)、SetTimer(タイマーを仕掛ける)、SetImeOpen(IMEのON/OFFを切り替える)といった具体的な操作の列(Effect)でした。SendInputのような操作は実際には数十ミリ秒かかることがあり、フックの時間の制約に対して無視できない長さでした。そこでEffectはその場で実行されず、時間の制約を受けないメッセージループ側で一つずつ実行されます。
判断を値として書き出し、実行を別の場所に任せるという考え方自体は、awase固有のものではありません。メールソフトで「送信」を押した瞬間にメールが直接ネットワークへ流れるのではなく、いったん送信トレイに置かれてから、バックグラウンドの処理が順番に送り出すのも同じ構造です。ソフトウェア設計の分野では、判断を行う中心部を副作用のない純粋な関数にまとめる設計が「Functional Core, Imperative Shell」と呼ばれます。awaseの工夫は、この発想をWindowsフックという具体的な時間制約へ当てはめた点にありました。
この分離は、テストのしやすさにも直結していました。engineが返すのはEffectという値の列でしかないため、本物のキーボードフックやIMEを用意しなくても、正しいEffectの列が返ってくるかだけで判断ロジックを検証できます。実機で手打ちして確かめる数分と、テスト一回あたり数秒の差は、一日に試せる変更の回数に直接効いてきます。その翌日には、判断だけでなく観測の側も、責務ごとに層を分け直す改修が続きました。
型を分け、資源の寿命を型に委ね、判断と実行を分ける。三つの変更に共通していたのは、「たまたま今回は大丈夫だった」という状態をそのままにはしなかったという姿勢です。型も資源の解放も、副作用の呼び出し口の少なさも、たまたまそうだっただけでした。動いた経路を、動いた理由の側から一つずつ点検し直した数日だったと言えます。
この経路が答えていないのは、タイミングという一つの物差しだけで、同時打鍵かどうかをいつも判定し切れるのか、という問いです。
設計原則
原則: 常駐して全キー入力を横取りし、加工してから相手に渡すツールでは、「正しく変換する」ことより「入力を失わない」こと、そして「壊れる場所を狭く保つ」ことを優先します。
適用条件: すべてのキー入力を横取りする常駐型ツール全般に働きます。特に、時間の制約がある場所(フックコールバックのような場所)で判断や実行まで行おうとする場面で強く働きます。
実装の形: 実装の形は三段階です。正常時は配列変換を適用します。異常を検知した時点では、変換を諦めて元の入力をそのまま通します(PassThroughフォールバック)。重大な異常が続く場合は、変換そのものを自動停止し、トレイ通知でユーザーに知らせます。どの段階でも、キーボードから入力そのものが失われることはなく、再開はユーザーの明示的な操作を待ちます。これに加えて、時間の制約がある場所は信号を出すだけに絞って判断と実行を別の場所に置き、同じ基本型に複数の意味が乗っている値はnewtypeで分け、確保と解放が対になる資源はガード型に包みます。
限界: この構造が防ぐのは、内部の責務が混ざることと、不具合時に入力そのものが失われることだけです。同時打鍵の判定がどれだけ的確か、外部から届く観測が信頼できるかどうかは、この構造だけでは何も保証しません。
第2章:「よかった」が「よがんた」になる

「よかった」と打ったつもりが、画面には「よがんた」という文字列が出ることがありました。
ローマ字入力ではなく、親指キーとの同時打鍵で仮名を決めるNICOLA方式ならではの化け方です。
入力を受け取る側のコードに誤りがあったわけではありません。二つのキーがほぼ同時に押された
とき、その「ほぼ同時」をどちらの文字に結びつけるべきかという判定そのものが、際どい場合には
決めようがなかったのです。
第1章では、キーを受け取り、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スコアは、タイミングに取って代わるものではありません。呼び出されるのは、
タイミングが際どく単独では決め切れない場合だけでした。
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にも、同じ手順がやや詳しく記されています。
日本語の百科事典の本文まるごとを、意味のある単語としてではなく、ひらがなの連鎖という
音の並びとして数え直す。もとは知識を記述するための文章が、ここでは「どの仮名の後に
どの仮名が続きやすいか」という、タイミング判定のための統計値に姿を変えています。
生成過程を実行した具体的なツールも、探してみると見つかりました。build-ngramという、
awase本体とは別の小さなRustプロジェクトです。Wikipediaのダンプから短すぎる文を除いて
取り出し、Sudachi.rsで形態素解析してからbigram・trigramの出現頻度を数え上げます。
この過程では「今日」は次に「は」が続けば「コンニチ」(こんにちは)、それ以外は「キョウ」と
読むというように、同じ表記でも前後の文脈で読みが変わる語(多読字)をルールベースの表で
補正しています。
このツール自体のファイルが最後に書き換えられた時刻を見ると、awase本体にNgramModelが
組み込まれたコミット(21時17分)より何時間も前、同じ3月28日の早朝から午後にかけて、
Wikipediaを処理するための専用ツールが先に作られていたことが分かります。プロトタイプが
動いた同じ一日のうちに、コーパスを作る側の作業まで進んでいたのです。
生成されたデータは、決して小さくありませんでした。TOML形式で5.7MB、25万エントリ。
これをアプリに同梱するには重すぎます。3月30日、CSV+gzip形式への変換で1.3MB(23時41分の
コミット)まで圧縮し、さらに8分後、スコアが一定より低い低頻度のtrigramを間引くことで
240KBまで削りました。25万エントリのうち、実際に残されたのは4万3千エントリほどです。
どちらの段階でどれだけ小さくなったかをまとめると、次のようになります。

際どさの外側では、統計は口を出さない
n-gramスコアの使われ方をもう一度確認します。frequency_score()はまずtrigramを参照し、
無ければbigramにフォールバックし、それでも無ければ0.0という中立値を返します。このスコアを
tanhで-1から1の範囲に正規化し、既定では前後20ミリ秒の調整幅に掛けて、30ミリ秒から
120ミリ秒という範囲に収まるよう閾値を動かします。
大事なのは、この仕組みが「かな漢字変換の候補を選ぶ」ためのものではないという点です。
awase自身は、かな漢字変換を行いません。それはOS側のIMEの役目です。awaseが決めているのは、
あくまで「どの一文字の仮名を確定させるか」という、もっと手前の一点だけでした。
2026年5月8日、1f93e7dというコミットで、このn-gramモデルを設定ファイル上で明示的に
有効化する変更が入りました。コミットメッセージには、この章の冒頭で挙げた「よかった」が
「よがんた」になる現象が、3キー仲裁でn-gramモデルが効いていないことに起因すると
記録されています。
この3キー仲裁を呼び出す側のNicolaFsm自体にも、見過ごされていた問題がありました。
初期の実装は、再帰呼び出しによるresolve・handle_idle・combine_prev_and_newという
形で書かれていました。動いてはいましたが、読みやすいとは言えないコードでした。3月30日の
ADR-015は、この実装が抱える問題を次の三点に絞って言語化しています。
update_history() が finalize_plan() の外でも呼ばれる(履歴更新の経路が分散)
combine_prev_and_new() で2つの Response を手動マージ(脆い)
self.phys の暗黙依存(再帰の奥で何を参照しているか不明)
ADR-015が採った解決は、起こりうる遷移のパターンをあらかじめParseActionという列挙型に
書き出すことでした。実装を担ったClaude Opus 4.6が積んだ同日中の二つのコミット
(0edf8e8・526f898)で、手動マージだったcombine_prev_and_newは削除され、分散していた
update_historyの呼び出しもループ内の一箇所に集約されています。「何が問題か」を先に
三点へ絞って言語化してから実装を任せる進め方は、この章のn-gramモデルと同じく、際どい
判定を確実に処理へ落とし込むための工夫の一つでした。
他分野への転用
際どい判定にだけ、独立した手がかりを足す
一つの観測だけでは判定が際どくなる場面は、日本語入力に限りません。予測変換やオート
コンプリートも、直前の入力だけでは次の候補を絞り切れないとき、単語の出現頻度という
統計的な文脈を判定材料に加えます。センサーフュージョンの分野でも、単一のセンサー値が
拮抗して読み取れないとき、過去の動きのパターンや周囲の情報といった、観測そのものとは
別の手がかりを組み合わせる設計が広く使われています。awaseがタイミングにn-gramを足した
構図は、この一般的なパターンの一例にすぎません。
ただし、この組み合わせは万能ではありません。観測同士の差が十分大きいときには、単一の
観測をそのまま信じるほうが速く、かつ正確です。統計的な補正は、観測同士が拮抗している、
まさにその瞬間にだけ呼び出されるべきものであり、常時介入させると、かえって本来明確
だったはずの判断を曖昧にしてしまいます。
タイミングが意味を持つ入力は、層に分けにくい
NICOLA方式のように、キーを押す間隔そのものが確定する文字を左右する入力方式は、この本が
初めて出会った設計ではありません。身近な例で言えば、スマートフォンの画面が「タップ」と
「長押し」を区別するのも、同じ仕組みです。指を離すタイミングだけで、まったく違う動作が
起こります。
この種の入力に共通するのは、「まず状況を把握してから、それに基づいて決める」という素直な
二段階では割り切れない、という性質です。ロボティクスの分野では、知覚・計画・行動という
決まった順序でパイプラインを組む設計に対し、Rodney Brooksが提唱したSubsumption
Architectureが、その順序そのものへ異議を唱えたことで知られています。NICOLA方式が
「タイミングが意味そのものを担っている場合、判定を知覚と決定にきれいに層分離できない」と
教えてくれるのは、この一般論を、親指シフトという具体的な入力方式でなぞり直した結果に
すぎません。
設計原則
原則: 一つの観測だけでは判定が際どくなる場面には、独立した第二の手がかりを用意しておきます。
適用条件: 観測同士の差が十分に大きい場合は、単一の観測だけで判定して構いません。第二の手がかりが必要になるのは、観測が拮抗し、単独では決め切れない場合に限られます。
実装の形: 主たる観測(このawaseの例ではタイミング差)による判定を基本とし、差が閾値を下回るときにだけ、統計的な文脈や事前知識といった別種の手がかりを参照します。
限界: 第二の手がかり自体も、確率的な傾向にすぎません。まれな連鎖や、統計に現れない新しい文脈に対しては、判定を誤る余地が残ります。
第3章:最初の一文字が化ける

第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う」になる例もありました。原因を一つに絞り込む前に、まず現れ方の共通性だけが先に積み上がっていきました。
この種の症状は、狙って再現するのが難しいという性質も持っていました。何度打っても化けない試行が続いた後、忘れた頃に同じ化け方が現れます。そのたびに、前回の修正が本当に効いていたのかを、あらためて疑う必要がありました。
見た目が同じであることは、原因が同じであることを意味しません。しかし当時の私は、この二つを区別せずに調査を始めました。「前回直したはずの箇所を、また壊したのだろう」というのが、最初に浮かんだ説明でした。
この思い込みには理由がありました。バグ報告は、コードの内部構造ではなく、画面に映った結果として届きます。同じ結果が届けば、同じ原因を疑うのは自然な反応です。しかし原因の側から見ると、一つの結果に至る経路は一つとは限りません。
調査の手がかりは、当時のコミット履歴とADRに残された記録でした。どちらも後から読み返して初めて、二つの事件が別の系統に属することが分かります。渦中にいる間は、どちらも「最初の一文字が化ける」という同じ一行の症状としてしか見えていませんでした。
送信順序を逆にしたら、直った
最初に疑ったのは、キーイベントを送る順序でした。Windows Terminalは独自のTSF(Text Services Framework、Windowsの入力方式を仲介する仕組み)テキストストアを持つアプリケーションで、キーの上げ下げをどう束ねて送るかによって、IME側の合成バッファが化けることがありました。K↑のタイミングで合成バッファがコミットされてしまうなら、キーを束ねる順序を変えれば防げるはずだ、というのが当初の仮説でした。
一般的なWin32アプリでは、送るキーの順序を気にする必要はほとんどありませんでした。IMEとの仲介をOSが単純な形で行ってくれるためです。Windows Terminalのように独自のテキストストアを実装しているアプリだけが、送信順序に敏感に反応しました。
2026年4月11日のd35dc09で、アプリの種類ごとに出力方式を切り替える設計を導入しました。その後も分類ロジックを何度か組み替え、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のコールドスタート問題でした。
送信順序の問題は「どちらを先に送るか」という、二択に近い形をしていました。しかしコールドスタートの問題は、「どれだけ待てば十分か」という、際限のない調整を要求する形をしていました。二つの事件を並べると、この違いがはっきりします。

二択の問題は、どちらかを選び切った時点で終わります。実際、送信順序は4月29日の確定以降、再発していません。一方、時間の見積もりの問題には、選び切るという終着点がありません。どれだけ調整しても、次の環境でまた足りなくなる可能性が残ります。
この違いに気づいてからは、調査の進め方も変わりました。症状が同じだからといって前回の修正を疑うのではなく、まず前回の修正が今回も効いているかを確かめる。効いていなければ、原因は別の場所にあると考える。当たり前のようですが、渦中では見失いやすい順序でした。
対策として採用されたのが、IMEをONにした直後に一定時間だけ待ってからローマ字を送る、待機時間の追加でした。ADR-0002はこの待機を「TSFコールドスタート・ウォームアップ」と呼んでいます。5月4日の1703fcfで、ウォームアップ用にVK_DBE_HIRAGANA(16進数で0xF2、以下「F2」と表記。キーボード最上段のファンクションキーF2とは無関係のIME専用仮想キーコード)を先行送信する仕組みを導入しました。翌5月5日のbabce4cでは、この「F2」とローマ字キーを別々のSendInputに分けています。同じバッチで送ると、IME初期化が終わる前にローマ字側が処理されてしまうと分かったためです。
このあとも細かな調整が続きました。特殊なスキャンコード(キーボードが物理的なキーごとに割り当てる識別番号)の付加や、ウォームアップに使う値を「F2」からVK_IME_ONへ差し替える変更などです。一つひとつは小さくても、合わせると迷走と呼べる量になっていました。
この試行錯誤は、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が提供する仕組み)への切り替えと差し戻しを経て、dbda95fでは要因ごとに異なる待機値(NativeF2Consumedなら1000ミリ秒、それ以外なら500ミリ秒)を割り当てるColdReason型が導入されました。単一の固定値から、状況に応じて値を変える発想への最初の一歩です。
待つのをやめて相手の内部状態を直接覗こうとする試みも二つありました。5月18日のsession.ipc(IME側のプロセス間通信ファイル)へのアクセス時刻監視と、メッセージキューへの空メッセージ送信です。後者は同じ日のうちに「効果がなく、むしろ悪化した」として取り消されています。ここまでの四つの試行――スキャンコード付加、値の差し替え、session.ipc監視、空メッセージ送信――は、いずれも相手の内部を外側から覗こうとする試みだったという点で共通していました。
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ミリ秒は環境によって変わるため、どれだけ精密に測っても次に同じ数字が出る保証はありませんでした。
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へ、待たずに正しく届きます。
この設計にも限界は残っています。文字列全体がリテラル化した場合は検出できますが、一部の文字だけがリテラル化し、かつIMEの候補表示自体は正常に発火した場合は、成功したと誤って判定されたままでした。候補表示という一つの合図だけでは、内部の文字列がどこまで正しく渡ったかを区別できなかったのです。利用者から見れば、全体がローマ字のまま残るよりも一部だけ混ざる方がかえって気づきにくく、この部分リテラルの検出は、今も未解決の課題として残っています。
この往復から、後にひとつの規約が生まれています。rust-nicolaの.claude/rules/tuning-constants.mdは、タイミング定数を変更するコミットに、実測値を本文へ書くことを義務づけています。「効かないので増やした」という理由だけの変更は禁止し、値を動かす前に、その定数が支配する待機が実際に何ミリ秒必要かを実機で測ることを求める規約です。500ミリ秒から600、1500、そして500へという往復のうち、実測値を添えていたのは83d5707だけでした。その実測でさえ、1時間29分後には別の理由で覆っています。このルールが生まれたのは、この章の出来事よりも後のことです。しかし当時AIへの指示に欠けていたのは、まさにこの一行、実測を書けという一文でした。
他分野への転用
朝、取引先に電話をかける前に「もう始業しているはずだ」と決め打ちして掛けると、まだ準備中で出てもらえないことがあります。逆に、確実を期して昼まで待てば、今度は相手を待たせすぎたことになります。「相手の準備が整うまでにかかる時間」を外から言い当てようとする限り、早すぎるか遅すぎるかのどちらかに振れ続けます。
分散システムの世界には、この問題に正面から取り組んだ仕組みがあります。Kubernetes(コンテナ化されたアプリケーションを多数のサーバーにまたがって運用するための基盤ソフトウェア)は、新しく起動したコンテナに通信を回してよいかを、起動から何秒経ったかという固定時間では判断しません。readinessプローブと呼ばれる確認要求を実際に送り、応答が返ってくるまで通信を回さないという設計を採っています。同じ仕組みには、動作中のコンテナが実は固まっていないかを継続的に確かめるlivenessプローブという、別の役割の確認要求もあります。livenessという言葉は、後の章で安全性(safety)と対になる形で、もう一度別の角度から登場します。
ただしKubernetesのreadinessプローブも、確認のための通信を追加で送るという代償を払っています。「完了を検出できる合図に置き換える」という原則は共通していても、その合図をどう安く手に入れるかは、対象ごとに異なる問題として残ります。
設計原則
原則: 対象の処理が完了したかどうかを、経過時間の長さで言い当てようとすると、いずれ実際の所要時間との差が顕在化します。値をどれだけ調整しても、待機時間という近似の形そのものは変わりません。
適用条件: 相手側の処理時間が環境やタイミングによって変動する場合に働きます。相手が自分の管理下にない外部プロセスで、内部実装を知る手段がない場合、処理時間が一定である保証はまず得られません。処理時間が完全に一定であることが保証されているなら、待機時間による近似でも実用上問題は起きません。
実装の形: 待機時間を調整するのではなく、完了そのものを検出できる合図に置き換えます。事前に賭けるのではなく、事後に確認して、必要なら取り消して直すという順序に変えることが要点です。ただし、完了の合図が来ない場合に無限に待ち続けるわけにはいかないため、タイムアウトは状態の代用品としてではなく、異常を打ち切るための安全弁として残します。
限界: この原則は「完了したかどうか」を確認する手段があることを前提にしています。確認の手段そのものが得られない相手に対しては、この原則だけでは足りません。確認する方法自体をどう見つけるかは、別の問題として残ります。
第4章:「わからない」を値にする

DecisionExecutorという構造体には、次のようなフィールドがありました。
applied_snapshot: Option<(bool, u64)>
RustのOption型は、値の「存在する」場合と「存在しない」場合を型として区別する仕組みです。
ここでのboolはIMEのONかOFFか、u64は確認時刻を表す、一見ただの値の組です。しかし
当時の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する際、確認を待たずまず自分の側の値を
先に書き換えます。確認を待ってから次のキー入力を処理すると入力への反応が遅れて見えるため、
確認前の値を先に仮置きする設計そのものは妥当でした。そのため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に限った習慣ではなく、境界条件の
判定を誤らせる典型的な原因の一つです。applied_snapshotの場合、その誤りがIMEの状態という
利用者の目に見える挙動へ直接つながっていました。
「IMEはboolで十分だ」という前提が崩れた
applied_snapshotが複雑化する前、awaseの前提はもっと単純でした。IMEの状態はONかOFFかの
boolであり、awase側でキャッシュしておけば十分だという前提です。この前提はしばらく機能して
いましたが、Win11のメモ帳やChromeのようなModern UIアプリで崩れました。IMM32は、Windowsが
古くから用意している、IMEとやり取りするAPI群です。旧来のWin32アプリでは
IMM32によるクロスプロセス検出がおおむね素直に働き、awase側のキャッシュとOS側の実際の値が
食い違う場面はまれでした。前提が単純なままで済んでいたのは、対象アプリが単純だったから
にすぎません。
ADR-005によれば、これらのアプリではクロスプロセスIME検出が常にopen=0を返します。実際には
IMEがONであっても、OS側の検出APIは「OFF」としか答えません。この検出の失敗を吸収するために、
awase自身が推測を保持するshadow stateへのフォールバックが導入されましたが、更新する仕組みが
まだ整っておらず、「IMEをOFFにしてもNICOLA変換が継続する」という一次バグが発生しました。
画面上の表示はOFFなのに、キー入力は同時打鍵の変換規則で処理され続けるという、利用者から見れば
表示と挙動が食い違う状態です。キャッシュする値そのものが正しくても、その値が「いつ、何によって
確認されたか」を保持していなければ、古いキャッシュと新しい現実の区別がつかなくなります。
この問題を掘り下げる過程で、ADR-029は既存の類似ツール(AutoHotkey/zenhan/alt-ime-ahk/
Keyhac等)も調査しています。IMM32はスレッドローカル設計であり別プロセスのIME状態を覗こうと
すると信頼できない値を返しやすく、Chrome・UWP・ElectronではWM_IME_CONTROLによるブリッジも
機能しません。広く使われているこれらのツールでも同じ壁は越えられておらず、awase固有の
実装不足ではなくWindowsのアーキテクチャそのものに起因する制約だと、ADR-029は結論づけて
います。クロスプロセスIME検出には完全な解決策が存在しません。
調査は、他のツールを外から眺めるだけでは終わりませんでした。TSFのITfCompartmentEventSink
という通知の仕組みをAIに実装させて動かしたところ、GUID_COMPARTMENT_KEYBOARD_OPENCLOSEという
コンパートメントがthread-manager(スレッド単位の管理主体)のスコープに閉じており、別プロセスへの
切り替えには原理的に通知が届かないと分かりました。ADR-029は、動かないと分かったこの仕組みを
削除したことも記録しています。
解決策が存在しない以上、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を置き換える目的で後から加わったAPIです。
同じ時期、awaseはIMM32・TSF・GJIのI/Oカウンタ・フォーカス変化の観測という複数の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という
消極的な不在からは読み取れませんでした。
Optimistic(bool)――OSにはまだ確認されていない
ユーザー操作に応じてawaseが値を書き換えた直後、非同期のapply処理が完了する前の状態です。
Optimisticという名前自体が、「この値は仮のものであり、まだ裏付けが取れていない」という
約束を運びます。この値を参照するコードは、名前を見た時点で「確定した事実として扱ってよいか」
を判断でき、ts == 0という条件式を思い出す必要はありません。
Confirmed { open, at_ms }――確認できた時刻ごと保持する
実際にOSへの反映が完了し、確認が取れた状態です。at_msは「確認できた時刻」であり、この状態
だけが後続の判断に安心して使ってよい値です。at_msという時刻そのものは、この章の範囲では
「いつ確認したか」を記録するだけの情報にとどまり、その時刻からどれだけ経てば古いと
見なすべきかという判定には、まだ使われていません。
三つのバリアントへの遷移も明文化され、以前は各所に散らばっていた条件分岐が、遷移が
バリアントに紐づいたことでどの経路がどの状態を生み出すかを一箇所で見渡せるようになりました。
型を入れ替えた効果は、実際のコードの前後を比べるとわかります。
// 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>のままでも三つの状態を表現しようと思えば表現できたはずですが、それでは
Someの中身が「仮の値」か「確認済みの値」かはコメントか呼び出し側の注意力に頼ることに
なります。三つのバリアントを別の名前に分けたことで、コンパイラが「まだOptimisticの段階なのに
Confirmedとして扱っていないか」を検査できるようになりました。
Physical AIへの接続
センサーの値にも、確認済みと推定の違いがある
この設計は、IME固有の工夫には見えません。ロボットが扱うセンサー値にも、同じ構造があります。
障害物までの距離や自己位置の推定値は、「検出できたか、できなかったか」という二値に丸めた
瞬間に、「かつて確認された値」と「いま確認できていない値」の違いが失われます。値そのものを
持っているかどうかと、その値を今の判断に使ってよいかどうかは、別の軸です。センサーが一度も
値を返していない状態と、直近まで値を返していたが今は途切れている状態も、二値の成功/失敗
フラグでは区別できません。
Unknown・Optimistic・Confirmedという区別は、この二つの軸を型として分離する、
一つの実装パターンにすぎません。センサーの値がいつ観測されたものかを型に含めておけば、
「値はあるが古いかもしれない」状態を、「値がまったくない」状態と混同せずに済みます。二値の
成功/失敗フラグでは、この二つの状態は同じ「失敗」に潰れてしまいます。
判断を下す側のコードにとって、この違いは軽視できません。「値がない」なら安全側にフォール
バックする以外の選択肢はありませんが、「値はあるが未確認」なら、その値を仮の判断材料として
使いつつ、確認が取れ次第上書きするという振る舞いを選べます。二つの状態を一つに潰した設計は、
この選択肢そのものをコードから奪ってしまいます。
ただし、この型が保証する範囲には限りがあります。Confirmedは「確認できた」という事実だけを
運び、「その確認がいつまで有効か」までは答えません。確信度を型で表現できても、その確信度が
古くなる境界線は、まだこの章の設計の外にあります。
ts = 0という一つの整数から始まったこの章は、結局のところ「値の中身」と「値の由来」を
同じ場所に押し込めないという規律に行き着きました。人間の注意力に頼っていた区別が型検査の
対象に変わったことが実質的な変化であり、この規律が答えなかった、由来が確かだった値が
いつまで確かでいられるかという問いは、次の観測が積み重なるほど重みを増していきます。
設計原則
原則:「わからない」は失敗として握りつぶすのではなく、明示的な値として持たせます。
適用条件: 観測の確信度が複数の段階(未観測・仮の値・確認済みなど)に分かれ、それらを区別しないと後続の判断を誤りうる場合に適用します。
実装の形: Optionやbooleanにセンチネル値を隠すのではなく、確信度ごとに列挙型のバリアントを分け、各バリアントがどの条件で生成されるかをコード上に明文化します。
限界: この型は「今何を知っているか」を表現するに留まります。「その知識がいつ古くなるか」を保証するものではありません。
第5章:過去から届く観測

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

最初は、見たことのない新種のバグに見えました。しかし調べていくうちに既視感がありました。
似た構造の問題には、すでに5月の時点で一度向き合っていたはずだったのです。当時の
コミットログを掘り返すと、確かに同じ主題を扱ったリファクタリングが4回も連なっていました。
「解決した」はずだった問題は、実は解決していなかったのです。
この章は、なぜこの問題が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、単一の真実源)にすればよい。IMM32(Input Method Manager、Windowsが長く提供して
きたIME連携の旧来API)によるクロスプロセス検出、TSF(Text Services Framework、その
後継の仕組み)の通知、GJI(Google Japanese Input、Google 日本語入力)のI/O観測という
複数の経路が、それぞれ別のタイミングで「今のIME状態はこうだ」と申告してきます。どの
申告を信じるかという判断が、コードベースのあちこちに散らばってしまうのは当然でした。
この仮説のもとで、同じ構造物に4回名前を変えながら実装をやり直すことになります。
改名遍歴——名前を変えるたびに、答えようとした問いが変わっていた
以下の4つの名前は、単なる呼び方の変遷ではありません。それぞれが「この構造物は何に
答えるべきか」という問いの立て直しでした。
| 名前 | 答えようとしていた問い | 隠れていた仮定 |
|---|
BeliefStore | 現在何を信じるか | 信念は一つに集めればよい |
ImeApplyLatch | 適用済みか | 適用履歴が次の判断を制御できる |
LastAppliedImeState | 最後に何を送ったか | 最後の命令が現在状態を代表する |
shadow_model | システムの現在状態は何か | SSOTなら競合はなくなる |
こうして並べてみると、4つの名前は退化しているようにも、進化しているようにも見えます。
最初のBeliefStoreは「信じる」という認識的な言葉を使い、次のImeApplyLatchは
「適用する」という行為の言葉に変わり、LastAppliedImeStateは「最後に何を送ったか」
という履歴の言葉に変わり、shadow_modelは「システムの現在状態」という存在論的な言葉に
戻ります。振り返ってみれば、毎回「この構造物の役割は何か」が問い直され、
少しずつ違う答えにたどり着いていたことになります。しかもこの問い直しは、思いつきで
起きたわけではありません。それぞれの改名の直前には、GJIがポーリング方式から
observer_poll経由の通知方式へ切り替わったことや、「適用ログ」と「真実源」を混同していた
ことへの気づきといった、具体的なコード側の変化がありました。以下の4つのコミットは、
その変化のたびに何が引き金になったかを示しています。
日付を並べると、最初の2つの改名がいかに急いで行われたかが分かります。BeliefStoreから
ImeApplyLatchへの改名(f8dd8d4)はわずか42分後、ImeApplyLatchから
LastAppliedImeStateへの改名(6baabf9)は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へ降格させる。これは第4章で扱った「わからない」を型として持つという発想の、
IME状態版の応用でもありました。しかしこの3値モデルは42分後のf8dd8d4で単純なラッチへ、
2日後の6baabf9でさらに「最後に何を送ったか」という履歴の言葉へと後退します。
「適用ログ」と「真実源」は別物だと気づかれ始めた段階でしたが、真実源そのものはまだ
Preconditions.ime_onという別の場所に置かれたままでした。
最後の到達点は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への一本化が完了しました。分散していた信念の置き場所は、
たった一つの構造物に集約されたのです。実に7日間、4つの名前を経て、「信念の置き場所は
1つである」という当初の目標そのものは達成されました。
名前を決め切れなかったのは、対象の責務を決め切れていなかったからです。「信じる」
「適用する」「最後に送る」「現在の状態を表す」は、どれも似て聞こえますが別の約束でした。
コードの中で頻繁に名前が変わる構造物を見つけたら、それは実装が下手なのではなく、その
構造物の責務そのものがまだ固まっていない徴候だと考えてよいでしょう。同じ傾向は、後の
第9章で扱うADR-032の4層モデルへの移行でも見られます。
shadow modelは何を解決したか
ここで一度、この一本化の成果を正当に認めておく必要があります。shadow_modelへの
統合は失敗ではありませんでした。統合前は、IME状態を知りたいときにImeBeliefStoreの
フィールド、Preconditions.ime_on、ImeApplyLatchの残骸のうちどれを見ればよいか、
コードを読むたびに追い直す必要がありました。統合後は、見るべき場所はshadow_model
ただ一つになりました。信念の置き場所が一つに定まったことで、「どのフィールドを
見れば今の状態が分かるか」というコードレビュー時の混乱は明確に解消しました。この直後、
ADR-032(Architecture Decision Record、設計判断とその理由を記録しておく文書)が
新設され、「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()
同じ時期、もう一つの小さな防御も別の場所に用意されていました。非同期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を作った動機とまったく
同じ言葉だったからです。今度は信念の置き場所ではなく、信念の鮮度をどう判定するかという
ロジックが、同じように複製され、分散していました。集約すればSSOT問題は解決するという
当初の仮説は、不十分だったのです。単一の場所に集めることと、古い観測を弾くことは、
まったく別の問題でした。前者を3回やり直しても、後者は5月29日の時点でまだ何一つ
解決していなかったのです。
Fix Aが動いていた数日間は、一度「これで直った」ように見えました。しかし直ったのは
「200ミリ秒以内に古い観測が届く場合」だけであり、それより遅れて届く場合や、逆に本当に
新しい観測を誤って疑わしいと判定してしまう場合は、まだ手つかずのままでした。整理が
終わっていたのは「信念をどこに置くか」だけであり、「その信念をいつまで信じてよいか」は
最初から範囲の外にあったのです。
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 モジュールを追加。
ここで発想が変わりました。「鮮度」は経過時間の近似ではなく、同一の文脈(フォーカス)の下で
発行されたかという、離散的な世代の一致として扱われるようになったのです。時間を数えるのを
やめて世代(整数)を照合するように変えた瞬間、問題は近似ではなく正確な判定になりました。
受理と棄却の分かれ目を、同じ「probe完了」というイベントが二通りの結末をたどる様子で見て
みます。

分かれ目は「何ミリ秒かかったか」ではなく、「発行してから完了するまでにフォーカスが
変わったか」だけです。フォーカスが変わっていなければEpochは一致し、変わっていれば
一致しません。ただし、この受理層がすべての観測経路を覆ったわけではありません。TSF・GJI・HwndCache
経由で届く一部の観測は、この時点でもエポック照合の対象外のまま残され、代わりに
3000ミリ秒という時間ベースの鮮度ウィンドウで足切りする設計にとどまっています。
「FocusEpochによって鮮度の問題はすべて解けた」と言い切るのは、まだ早いのです。
この問いが見落とされやすいのには理由があります。コードレビューの場でshadow_modelを
見せられれば、誰もが「信念は一箇所に集まっている」ことを確認できます。しかし「この信念が
どのフォーカス世界の産物か」は、shadow_modelの型定義を眺めるだけでは分かりません。
欠けているものは、フィールドの不足としては見えず、フィールドが記録していない時間軸として
しか現れないからです。存在しないフィールドの不在に気づくのは、存在するフィールドの誤りに
気づくよりも、はるかに難しいことでした。
たとえば、エディタAでの観測処理が完了しないうちにALT+TABでエディタBへ切り替わった場合、
時間ベースの判定は「届くまで何ミリ秒か」しか見ないため、両者を区別せずに採用してしまいます。
本来知りたいのは経過時間ではなく、その観測がどちらの世界に属するかという離散的な所属の
問題でした。
Physical AIへの接続
鮮度は一度の検査で終わる属性ではない
shadow_modelが答えられなかった問いは、ロボティクスの世界では以前から知られている
問題と同じ形をしています。ロボットのセンサーが返す値も、届いた時点ではすでに過去の
ものです。遅れて届く観測(delayed measurement)や、順序が入れ替わって届く観測
(out-of-sequence measurement)を扱う議論は、この分野で以前から蓄積されています。センサー
融合の実装で「信念(belief)を1つの状態推定器へ集約する」設計自体は、ロボティクスでも
古くから行われてきました。しかしそこでも、集約した状態推定器が「いつの観測に基づいて
いるか」を保持できていなければ、古いセンサー値が新しい状況を上書きする同じ事故が起こり
えます。信念を集約することと、集約した信念の鮮度を管理することは、ロボティクスにおいても
別の設計判断として扱われるべきものです。
もう一点、注意すべき限界があります。観測を受理する層で世代を照合できても、受理した観測を
実際に不可逆な作用へつなげるまでの間に、もう一度世代が変わってしまう可能性は、この設計
だけでは塞がれていません。鮮度は一度検査すれば終わる属性ではなく、不可逆な作用へ進む
すべての境界で、繰り返し再検証されるべき契約です。この規律は、観測を受け取る側だけでなく、
観測に基づいて実際に何かを実行する側にも同じ強度で適用されて初めて意味を持ちます。信念を
集約する設計だけを見て「もう鮮度の問題は解決した」と判断してしまうと、この2つ目の適用を
見落としたまま次の作業に進んでしまいます。
設計原則
原則: 観測には「いつのものか」が付いていないと、過去が現在を書き換えます。
適用条件: 非同期に届く観測や応答が、発行から到着までの間に、判断の前提となる文脈(フォーカス・セッション・接続先など)が変わりうる場面に適用できます。単一プロセス内の同期処理のように、発行と適用の間に文脈が変わりえない場面では、この原則を適用する必要はありません。逆に、文脈が変わりうるのに「めったに変わらないから」という理由でこの原則を省略すると、発生頻度が低いぶん原因の特定がかえって遅れます。
実装の形: 観測を発行した時点で文脈を表す世代番号を記録し、観測を採用する時点で現在の世代番号と照合します。一致しなければ棄却します。本当に必要だったのは集約場所ではなく、この観測は今の文脈でまだ有効かを照合する仕組みでした。時間(ミリ秒)で近似すると、負荷や遅延で簡単に破れます。世代(整数)で照合すると、時間に依存せず正確に判定できます。
限界: この原則は、観測を受け取る場面だけを保証します。観測を受理した後、実際に不可逆な作用へ進むまでの間に、文脈がもう一度変わってしまう場合があります。その間隙をこの原則はまだ塞いでいません。
第6章:メーターの針が、在宅を教えてくれる

型で状態を表現しても、それだけでは足りないことがあります。観測する経路そのものが、外部から汚染されている場合です。第III部では、判定条件を増やす、いわゆる「パッチ」を重ねるだけでは解けなかった問題を三つ扱います。
最初にぶつかったのは、状態を一切外に出さない相手でした。相手は自分のコードではなく、競合する別の日本語入力エンジン、GJI(Google Japanese Input、Google 日本語入力)です。公式なAPIで内部状態を尋ねる手段はなく、それでいて両者が同じ物理キーの入力を奪い合う場面があったため、知る手段がないまま送信を続けると、文字が正しく合成されない事故が起きました。
第6章から第8章までが、この第III部にあたります。局所的な対策の代わりに、問題の見方そのものを変える場面を三つの章に分けて描きます。
GJIは、活性化したかどうかを教えてくれなかった
待ち時間をあと200ミリ秒延ばすだけなら、設定を1行書き換えれば済むはずでした。実際には、そこから三つの方式を試し、四つの案を退けるまでに数週間を要しました。最後に残った答えは、待ち時間の調整ではありませんでした。
事の発端は、ChromeやBrave、Edgeのようなブラウザで起きた症状です。IMEをOFFからONへ切り替えた直後にローマ字を入力すると、「kおのなかで」のような部分的なリテラル化が発生しました。
原因の構造は、こうでした。これらのブラウザはVKモードで動作し、物理キーコードをそのまま受け取ります。GJIのcomposition contextは、活性化前の状態(cold状態)ではVKキーを素通りさせてしまいます。GJIをVK_DBE_HIRAGANA(第3章で触れた「F2」)で活性化させるまでには、数百ミリ秒の遅延がありました。この遅延の間に最初のVKキーが届くと、ひらがなに変換されないままリテラル文字として確定してしまいました。
この事故を防ぐには、GJIが「いつ活性化を終えたか」を知る必要がありました。しかし、GJIは競合する別会社の製品であり、内部状態を教えてくれる公式なインターフェースはありません。当初検討したのは、対象プロセスにコードを注入して内部の関数呼び出しを横取りするDLLフック、TSF(Text Services Framework、IMM32の後継としてWindowsが提供するIME連携の仕組み)経由で外部から状態を尋ねるCOM問い合わせ、プロセス間通信(IPC)を外側から覗き見る名前付きパイプの盗聴という三つの方向でした。
単純な待ち時間の調整も含めて、四つの具体案を実装し実機で確認した結果、侵襲性・安定性・バージョン依存・実装コストという四つの軸で見ると、それぞれ異なる理由ですべて却下でした。
| 案 | 決定的だった弱点 |
|---|
| 待機時間の延長(300ミリ秒→500ミリ秒) | 安定性。Windows Terminalの初期化遅延(344ミリ秒)に追随できず、時定数の調整自体が競合条件でした |
| DLLフックによるメッセージ傍受(WM_IME_STARTCOMPOSITION) | 侵襲性。DLLインジェクションが前提になり、アンチウイルスの誤検知リスクが大きすぎました |
| COM経由のクロスプロセス問い合わせ(ImmGetCompositionString) | バージョン依存。TSFネイティブなアプリでは常に0が返り、実験では952ミリ秒遅れて反映されることもありました |
| I/Oカウンタを時刻で見る方法(GetProcessIoCounters、時間ベース) | 実装コスト。ChromeのTSFコンテキスト更新は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」の処理だけで誤検知していたはずです。バイト数ベースに転換したことで、この誤検知を避けられました。二つの指標が、同じ二つの操作をどう見分けたか(見分けられなかったか)を図にすると、次のようになります。

時刻は「F2」と本物の合成のどちらでも動くため、両者を区別する材料になりません。バイト数だけが、モード切替キーの処理と実際の文字合成という、性質の異なる二つの操作を分けていました。
350バイトという数字は、GJIのこのバージョン・この環境で観測された値であり、普遍的な定数ではありません。GJIの実装が変われば、しきい値も引き直す必要があります。採用したコードでは、この値を固定の判定基準としてではなく、warm状態とcold状態を分ける経験的な境界として扱っています。
この差分も、第4章・第5章で見た意味での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の記録によれば、別呼び出しに分けるとChromeが描画するタイミングで捨て文字の'a'が一瞬画面に表示されてしまうためです。同一バッチであればOSがイベントキューに連続して積むため、画面には何も現れません。
観測の基準値(baseline)を取得するタイミングにも、見落としがありました。commit 26bc0feの記録によれば、VK_A送信後にbaselineを取得すると、cold状態のChromeがすでに書き込んだ+300バイトごとbaselineに吸収されてしまい、差分が0に見えてしまいます。baselineはVK_A送信前に取得しなければなりませんでした。単純な前後関係の取り違えが、しきい値判定そのものを無効化してしまう例です。
この仕組みは、当初すべてのcold-startに適用していました。ところがcommit d02ec44の記録によれば、TSFモード以外のアプリではVK_Aを送ってもHIDEイベントが来ないといった不具合が起き、かえって性能が落ちました。最終的には、長時間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のキーバインドに干渉しない。
ところがその6分後、06時34分のcommit 22c3905は、Chromeだけを元のVK_A+BS方式へ戻しています。
ChromeでVK_IME_OFFがTSF contextを壊す問題を修正(過去検証済み知見)。Chrome: VK_A+BS(元の方式)。Chrome内でvimが動くケースは稀でありVK_Aのvim問題は許容。
VK_Aはvimのappendコマンドに割り当てられており、cold状態でIMEを素通りするとターミナルへ誤って届く恐れがありました。一方、cold-start直後にVK_IME_OFF→ONを送ると、ChromeのTSFコンテキストそのものを壊してしまうという、より限定的な問題もありました。vimとの衝突というリスクとTSFコンテキスト崩壊というリスクを比べ、Chromeに関してはvim問題を許容する側を選んだことになります。
こうして捨て打ち機能は、Chrome+GJIの組み合わせにだけ残る技法になりました。ADR-063によれば、競合するIMEがMS-IMEの場合はTSFコンテキストが常にwarm状態であるため、そもそもこの種のプローブ自体が不要です。Windows Terminalのような他のTSFネイティブアプリは、IME制御キーだけを使うVK_IME_OFF→ON方式に置き換わっています。
この切り分けに至るまでに、ADR-063はほかに二つの案を検討し、退けています。
| 案 | 却下理由 |
|---|
| 案A: 全モードを一括置換する | GJI向けのcold probe機構(捨て打ち機能を含む)は複雑で、MS-IME向けに別実装を書き下ろすより、既存のStrategy抽象を使い回すほうが安全でした |
| 案C: KanjiToggleを改善するだけにとどめる | VK_KANJIはトグルキーであるため、想定と実際の状態がずれる「shadow desync」が起きると操作が反転してしまう問題が構造的に残ります |
残ったのは、TSF制御を「GJI/MS-IMEで共通の層」と「IME固有の層」に分け、既存のStrategyパターンで両者を分岐させる案(案B)でした。GJIには捨て打ち機能を、MS-IMEにはprobeなしの制御を割り当てています。
観測を担う部分の実装は、次のようになっています。
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の中に閉じ込め、しきい値との比較は外側に切り出したことで、後から350という値を調整し直す作業が軽くなりました。
同じ観測方法に、二度たどり着いた
この技法は、6月14日から24日にかけてのADR-048で最初に記録されました。Chromeのcold-start問題を解くための、SacrificialWarmup機構としてです。
同じ着想が、6月25日のADR-062でも独立に現れました。今度の対象は、まだ学習していない種類のTSFモードを自動判定するUnicodeLiteralObserverFsmでした。
ADR-062は、ADR-048よりもう一歩先に進んでいました。ADR-048は捨て文字のVK_Aを新たに送っていましたが、ADR-062はそれすら送っていません。どのみち送るはずだった本物のローマ字自体を、プローブとして転用していました。専用の観測用入力を足すこと自体が、新しい非決定性の発生源になり得るという判断です。
ここにたどり着くまでに、ADR-062はほかに二つの案を退けています。
| 案 | 却下理由 |
|---|
| probe専用のVKを新たに送る | リテラル化という、解決したい問題そのものを引き起こすリスクがありました |
| フォーカス時にウィンドウクラス名で静的に判別する | 初めて遭遇するクラスには対応できませんでした |
| 捨て打ち機能のVK_Aをそのまま流用する | 捨て打ち機能はTSFモード確定後にしか動かない仕組みのため、まだモードが分かっていない未学習クラスでは起動しませんでした |
残った一つ、ローマ字送信後に100ミリ秒待って観測する方式だけが、リテラル化のリスクも、未学習クラスへの対応漏れも避けられました。ADR-062には、その理由がこう記録されています。
gji_last_io_ms(時刻ベース)ではなくgji_write_bytes(バイト数ベース)を使う理由: F2(モード切り替え)もI/O時刻を更新するため誤検知するが、WriteTransferCountはF2で増加しない(w_KB=+0.0)ため分離できる。
10日あまり離れた二つの問題が、同じ観測方法にたどり着きました。偶然の一致ではなく、GJIの内部が見えないこと、時刻では区別できないことという同じ制約が、別の問題でも繰り返し現れたためだと考えられます。
この技法をawase固有の思いつきだと考えるのは、正確ではありません。電気やガス、水道の使用量を外から見るだけで部屋の中の様子を言い当てるのと同じで、郵便配達員が前日の郵便物がポストから無くなっているかどうかを見るだけで在宅を推測するのに近い発想です。ネットワークの世界にも、専用の確認要求を送らず通常の通信に相乗りして相手の状態を推測する受動的ヘルスチェックという手法があります(F5やNGINXが採用)。捨て打ち機能、そしてADR-062がたどり着いた「専用のプローブすら送らない」という徹底も、この系譜に連なるものです。
対象プロセスが何をしたかを、外から言い当てる話でした。まだ手つかずだったのは逆方向の問題です。こちらが送ったキーが、相手のアプリケーションにとって本物として解釈されるかという問題は、相手の状態を観測することではなく、送信する経路そのものの設計を必要としました。
設計原則
原則: 相手が状態を公開しないなら、相手に必要な操作をさせ、その副作用を数量として観測します。
適用条件: 対象プロセスに公式な状態取得手段がなく、代わりに何らかのシステムリソース(I/O・CPU・メモリ等)の変化を外部から読み取れる場合に使います。判定に使う指標は、目的の操作とそれ以外の操作を区別できるものを選びます。対象の状態が短時間で頻繁に切り替わる場合ほど、時刻ではなく量で判定できる指標を優先します。
実装の形: 本物の操作にプローブを便乗させ、専用の観測用入力を新たに作りません。しきい値は実測した複数サンプルから中間値として決め、コード上は経験的な境界として扱います。判定に使う値の取得順序(いつ基準値を取るか)も、しきい値そのものと同じくらい注意して決めます。
限界: しきい値は対象プロセスの具体的なバージョン・環境に依存し、普遍的な定数として扱えません。対象の実装が変われば、しきい値の再計測が必要になります。対象が想定していない状態にまで一律に適用すると、判定の空振りによってかえって性能が落ちることがあります。
第7章:フィルタでは勝てない

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回とも再発させました。実装を担当したのはAIモデルの
Claude Sonnet 4.6(以下、Sonnet)で、6件のコミットはすべてSonnetとの共著です。4回とも、私が
Sonnetに渡した指示の形は同じでした。「この反例が起きないよう、フィルタを直してほしい」という、
直前の反例だけを塞ぐ修正依頼です。翌日未明、私はその指示を繰り返すことをやめ、経路そのものを
渡さないよう設計を変える指示に切り替えました。この章で追うのは、その一晩の攻防と、指示の
立て方が変わった瞬間です。
これまでの章では、Windowsが返す観測が信用できないという問題を扱ってきました。届いた値を
そのまま信じず、belief(信念)として一段クッションを置く、という考え方です。しかし今回の
相手はOS自身ではなく、隣で動く別のアプリでした。LINEが生成する偽イベントは、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(かんぬき)を立てれば十分だろうという
仮説を立て、そのままSonnetへの指示に変えました。「操作直後の短い窓だけ、latchで抑制してほしい」
という依頼です。窓の外側で何かが起きるとは、このときはまだ考えていませんでした。自分が
送った操作の直後だけを疑い、それ以外の時間帯は届いた値をそのまま信じてよいという考え方は、
それまでの章で組み立ててきたbeliefの発想の延長線上にありました。
この仮説はすぐに崩れました。そこから1時39分のf84c74bまで、同じ形の反例と修正が3回続きます。
22時35分の77ccf34ではKeyUpという別の入口が、0時05分のe890a26ではVK_F4という別のキーが、
それぞれ抑制対象から漏れていました。
一晩で、同じ言葉が四回繰り返された
四段階の背後にあった実装は、shadow_toggle_suppressed_vksという許可リスト(HashSet)への
登録でした。抑制したいVKコードが見つかるたびに、このリストへ一つずつ追加していく仕組みで、
次のエッジケースが見つかるたびにまた一つ追加する運用を招いていました。Sonnetは渡された
指示を過不足なく実装しており、繰り返しの原因は同じ形の指示を渡していた私の側にありました。
それぞれの修正が何を本物のイベントだと仮定し、LINEがどの反例でその仮定を破ったかを見ると、
範囲の広げ方には共通の型があります。
| 時刻 | コミット | 何を本物のイベントだと仮定したか | LINEはどの反例でそれを破ったか |
|---|
| 21:00 | d99e3f1 | 自分がKANJI操作を送った直後の短い時間帯だけ、偽のVK_F3が来る | latchが想定していないタイミングでも同じ偽イベントが生成された |
| 22:35 | 77ccf34 | KeyUpの扱いを直せば、発生源はそれで塞げる | shadow stateを経由しない経路でも、同型の偽イベントが観測された |
| 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つの仮説を通して見ると、一つ塞ぐたびに次の穴が現れ、最後に経路そのものを断つ決断へ至る流れが見えます。

四つのコミットメッセージの語尾を追うと、「防止」「防止」「防止」ときて、最後だけ「断つ」に
変わっています。この変化は、新しい原因が次々見つかったというより、最初の仮説の立て方
そのものが狭すぎたことを、後から一つずつ思い知らされていた形に近いものでした。
この種の修正は、モグラ叩きと呼ばれる形をしています。一匹叩くたびに、その手応えだけを見れば
成功していました。しかし四段階を終えた時点で向き合っていたのは、個々の穴ではなく、モグラが
いくらでも出てこられる盤面そのものでした。盤面を変えない限り、フィルタをどれだけ足しても
追いつきません。
フィルタをやめ、経路を渡さないことにした
四回目の修正から20分後、1時59分に積まれた08b8661のメッセージは
「ImmCrossアプリでも物理KANJIをConsumeしspurious VK_F3/F4連鎖を遮断」でした。ここで、
Sonnetに渡す指示そのものを変えました。それまでの4回が「この反例を見分けて除外してほしい」
という修正依頼だったのに対し、5回目は「そもそも物理KANJIキーをLINEに渡さない設計にできないか」
という依頼に切り替えました。「見分ける」側の問いから、「渡す・渡さない」という別の軸の
問いへの転換です。
この切り替えに、劇的な発見や新しい調査は必要ありませんでした。必要だったのは、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行の削減になりました。四段階かけて積み上げた
「見分ける」ための仕組みは、「渡さない」という一行の条件に置き換わった時点で不要になったのです。
なぜこの転換が可能だったのかは、当時のメモfeedback_immcross_owns_kanjiに残っています。
ImmCrossプロファイルはset_ime_open_cross_processによって相手プロセスのIME開閉状態を
すでに直接書き込んでおり、物理KANJIキーをLINE自身に渡す必要はそもそもありませんでした。
フィルタを四段階重ねている間、私は「渡した後でどう見分けるか」しか考えていませんでしたが、
ImmCrossは渡すかどうかを選べる立場にすでにいたのです。
許可リストへの登録という形と、プロファイルごとの真偽値一つという形の違いは、状態を持つか
持たないかの違いでもあります。shadow_toggle_suppressed_vksは抑制対象の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状態をどう受け取るプロファイルかによって渡すか渡さないかを使い分けるという
発想は、この一晩で初めて明文化されました。四段階のフィルタが場当たり的な条件式の積み重ね
だったのに対し、この表は一度作れば以後の判定を必要としません。
Physical AIへの接続
動作を見た別のセンサーが、それを新しい入力だと誤認する
LINEが起こしていたのは、ロボティクスの世界にも似た構造の問題です。自分の関節モーターが
発する動作音を、同じ機体のマイクが拾い、それを新しい音声コマンドだと誤認識してしまう
フィードバックループがその例です。センサーの側から見れば、モーター音も外部からの音声入力も、
波形としては区別がつきません。区別できないものを区別しようとしてフィルタの条件を
増やし続けるか、あるいはそもそもマイクにモーター音を拾わせない(機構的な遮音、あるいは
発話区間だけマイクを有効化するゲーティング)かは、awaseが一晩でたどった
「フィルタ強化からチャネル遮断へ」という経路と同じ選択です。どちらを選べるかは、
自分がそのセンサーやアクチュエータの入出力経路をどこまで所有しているかで決まります。
機体の設計段階からマイクの配置や有効化タイミングを選べるなら、遮断は現実的な選択肢に
なります。既製のセンサーモジュールを後から組み込むだけの立場であれば、遮断という選択肢は
手元になく、フィルタを強化し続けるしかありません。
境界を持たない層では、フィルタしか選べない
この問題は、awaseが最初に直面したものではありませんでした。Firefox OSのIME層である
Gaia(bug 1110030)は、合成イベントと物理イベントが区別できないという同型の症状に対し、
IsSynthesizedByTIPという判別用フラグを追加する方向、つまりフィルタを強化する方向で
対応していました。awaseと逆の選択です。何が同じで、何が逆だったのかを図に整理します。

この違いは、どちらの設計が優れているかという話ではありません。渡す・渡さないを選べる
立場にいたか、判別するしかない立場にいたかという、境界の所有権の違いです。ImmCrossは
set_ime_open_cross_processによって対象アプリのIME状態をすでに直接所有していたからこそ、
渡さないという選択ができました。Gaiaは任意のWebアプリ上で動く汎用層で、個々のアプリの
入力処理を所有していなかったため、判別を磨くことしか選べなかったのです。
「排除は緩和に勝る」という原則は、この場面で初めて発見されたものではありません。小さな
子どものいる家庭を思い浮かべてください。「ストーブに近づいたら危ないよ」と繰り返し言い
聞かせる方法(注意で防ぐ対策)よりも、ストーブの周りに柵を置いて物理的に近づけなくする
方法(危険の原因そのものを取り除く対策)のほうが、確実に事故を防げます。労働安全の分野には、
この考え方を段階として整理した枠組みがあります(アメリカの労働安全衛生研究所NIOSHが示す
Hierarchy of Hazard Controlsが知られています)。危険源そのものを取り除く「排除」を、
注意書きや保護具よりも上位に置く考え方です。
ソフトウェア設計にも同じ発想があり、過去の日付を選べてしまってから後でエラーを出すのでは
なく、そもそもカレンダーの選択肢に表示しない作り方は、「あり得ない状態はそもそも表現できない
ようにする」(make illegal states unrepresentable、Yaron Minskyの言葉とされます)という
原則の一例です。ただしこれは値の表現方法についての原則であり、awaseが直面した入力チャネルの
所有権の問題とは厳密には別の話です。
この境界にも、期限があった
08b8661と0e364eaでの転換は、ImmCrossプロファイルが物理KANJIキーを所有できるという前提の
上に立っていました。しかしこの前提も、7月2日以降に揺らぎます。ImmCrossProbe自体の信頼性、
具体的にはQtアプリが子ウィジェットごとに別々のIMM32コンテキストを持つという問題が見つかり、
所有していたはずの境界が思っていたほど単純ではなかったことが分かりました。この判定は
のちにFocusEpoch/derive_open()というモデルへ統合されます。「このアプリはKANJIを
渡さなくてよい」という判定そのものも、突き詰めれば一つの観測にすぎません。渡す・渡さないを
一度決めて終わりではなく、その決定がどれだけ新鮮かを、別の仕組みで確認し続ける必要が
あったのです。
設計原則
原則: 排除は緩和に勝ります。合成入力と物理入力が構造的に区別できないとき、判定条件を増やすフィルタは行き詰まります。
適用条件: 入力の発生源そのものを自分の層が所有しており、渡す・渡さないを選べる場合に限られます。渡す前に握りつぶせる位置に、自分のコードが立っているかどうかが分かれ目です。
実装の形: 個々のケースを判定する条件式を増やすのではなく、対象を丸ごとConsumeする一本の分岐に置き換えます。条件を増やすたびにコードは複雑になりますが、経路を断つ実装はむしろ単純になります。
限界: この原則は自分がその入力経路を所有していない場合には適用できません。所有権が自分の層になければ、区別できないものを区別しようとするフィルタだけが残された手段になります。また、所有しているという前提自体も、時間が経てば崩れることがあります。
第8章:増殖した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」と呼ぶなら、この判定は複数の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)、時刻またはイベントを受け取って内部状態を進める操作さえ用意すれば、どんな観測対象でも同じ枠組みで扱えるようにしました。
この抽象化は狙いどおりに機能し、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が、SwitchMachineで機械を乗り換えていた
awaseのTSF(Text Services Framework)には、IMEをコールドスタートさせるための一連の手続きがありました。GJI状態の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月来の累積 |
生成コミットの日付が示すとおり、これらは一度に設計されたものではなく、個々のFSMはそれぞれが生まれた時点で発生していた問題への、その時点では妥当な回答でした。増えすぎたのは、後から振り返って初めて分かることです。
これらのFSMは互いに独立してはいましたが、一つのフェーズが終わると次のFSMに切り替えるSwitchMachineというアクションで連鎖していました(GjiWarmupFsmからLiteralDetectFsmへ、というように)。呼び出す側から見れば複数のフェーズが一つの手続きとして流れているように見えても、実装の内部では別々の型を渡り歩く構造になっていました。
この連鎖の構造には、増えるたびに重くなる代償がありました。フェーズを一つ追加するたびに、enumのバリアントとmatchのアームが線形に増えます。ローマ字文字列やバックスペースの回数のような一時的な値は、structのフィールドとして保持するかSwitchMachineのペイロードとして手渡すしかなく、フェーズが進むたびに「もう使わないが、まだstructには残っている」という状態を経由します。読む側は、あるフィールドがいまのフェーズで意味を持つのか前のフェーズの残骸なのかを、コード全体を追わないと判断できません。matchアームが増える負担と、フィールドの寿命が読み取りにくくなる負担は、同じ設計判断から生じた一対の症状でした。
一つの関数に直線で書いたら、969行が消えた
06月27日07時22分02秒、e548e63でtsf/step_coro.rsという新しい基盤が追加されました。StepCoroと呼ばれるこの仕組みは、unsafe(安全性チェックを一部省く書き方)も使わず、nightly(正式リリース前の実験的機能)にも外部クレートにも依存せず、標準ライブラリだけで動く軽量なコルーチンでした。
この形にたどり着くまでに、ADR-053には三つの選択肢が比較され、いずれも却下された記録が残っています。一つ目はtokioのような非同期ランタイムの導入で、awaseはタイマー駆動の単一スレッドモデルで動いており、外部ランタイムを持ち込むには荷が重すぎると判断されました。二つ目はgeneratorクレートやnightlyのコルーチン機能を使う案で、nightly依存か外部クレート依存のどちらかを新たに抱え込むことになり、個人開発で長期に保守する前提とは相性が悪いと判断されました。三つ目は単純に「FSMのまま拡張し続ける」案で、フェーズを追加するたびにstructのフィールドが増え続け、コードを読む側の負担が積み上がっていく欠点を抱えていました。三つとも別の理由で却下された結果、std限定でunsafeもnightlyも使わないStepCoroが選ばれています。
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への呼び出しも持たない、入力を受け取って次の出力を返すだけの型です。ADR-053には、この構造によってChromeProbeやLiteralDetectFsmが独立してユニットテスト可能になったと記録されています。
10分後の07時22分12秒、ccd4711が最初の適用例を追加し、GjiWarmupFsmとLiteralDetectFsmのチェーンをGjiWarmupCoroという1本のコルーチンにまとめ、StartLiteralDetect → SwitchMachine → LiteralDetectFsmという機械切り替えをインライン記述に置き換えています。コミットメッセージには「GjiWarmupFsmは削除せず残置」ともあり、この時点ではまだ古いFSMを消す踏ん切りがついていません。
7分後の07時29分35秒、f01d401がGjiWarmupFsm本体を削除し、ProbeAction::StartLiteralDetectバリアントも合わせて撤去しました。これが冒頭の-1055行です。コミットメッセージには「GjiWarmupCoroへの移行完了に伴い不要になったコードを削除する」とあります。フェーズ間の情報を局所変数として持ち運べるようになった結果、恒久的なフィールドとenumバリアントの大半が不要になり、削除できる状態になっていました。
この書き換えで変わったのは行数だけではありません。以前はGJI probeからLiteralDetectまでの手続きを追うのに5個のファイルをまたいでmatchアームとenumバリアントを行き来する必要がありましたが、書き換え後は同じ手続きが1本の関数の中で上から下へ読める順序で並んでいます。フェーズの数が増えても、増えるのは関数の行数であって、ファイルをまたぐmatchアームの数ではありません。
ここまでの経過だけを見ると、5つのFSMをすべて同じ手順でコルーチン化すればよい、という結論に飛びつきたくなります。
消えなかった一つ
ところが、LiteralDetectFsmというファイルは、f01d401のあとも削除されませんでした。crates/awase-windows/src/tsf/literal_detect_fsm.rsは現在も333行のFSMとして存在し続けています。
削除されなかったLiteralDetectFsmは、06月27日の変更で対象から外れた古い実装ではありません。コールドパスでの役割はコルーチンにインライン化されて消えましたが、ウォームパス(変換後にIME側の確定表示を待つ経路)では同じ名前の型が独立したFSMとして今も使われています。tsf/tickable_fsm.rsには「warmパスのpost-transmit composition確認」という注記が残っています。
同じ「リテラル検出」という処理でありながら、コールドパスでは関数の中の一節としてインライン化され、ウォームパスでは独立した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行)にまとめ、17時03分17秒のd1d6d17はTsfProbeMachine(827行)をTsfProbeCoroに置き換えて237行を削減しています。どちらも、Chrome再初期化前のウォームアップからProbeの3フェーズまで、順序が決まった一回限りの手続きであり、基準の左列にそのまま当てはまりました。
07時22分のGjiWarmupCoroから17時03分のTsfProbeCoroまで、3系統の書き換えは同じ一日のうちに終わっています。これは急いで済ませたということではなく、最初の書き換えで基準がはっきりしたあとは、残り二系統への適用に迷いが要らなかったことの表れです。判断に時間がかかったのは、基準を見つけるまでの最初の一系統だけでした。
3件の変更と1件の非変更をまとめると、次の対応関係になります。
| 変更前 | コミット | 変更後 |
|---|
GjiWarmupFsm(969行) | f01d401(07:29) | GjiWarmupCoroへ統合、ファイルごと削除 |
LiteralDetectFsm(コールドパスでの利用) | ccd4711(07:22) | GjiWarmupCoro内にインライン記述、型としての呼び出しは消滅 |
SacrificialWarmupFsm(299行) + ChromeGjiReinitFsm(95行) | 2c756dd(08:09) | SacrificialWarmupCoro(269行)へ統合 |
TsfProbeMachine(827行) | d1d6d17(17:03) | TsfProbeCoroへ統合、237行削減 |
LiteralDetectFsm(ウォームパスでの利用) | ― | 変更なし。唯一残ったFSM |
5個あったFSM型のうち、順序が決まった一回限りの手続きを担っていた4個(実体としては3系統)はコルーチンへ移り、状態そのものが問い合わせの対象になっていた1個だけがFSMのまま残りました。基準は3回とも同じでした。
境界線は引けたが、動く境界には答えていない
この基準は、StepCoroがFSMより優れているという主張ではありません。三つの代替案の却下は、あくまで「一回限りの手続きをどう書くか」という場面に限った判断でした。この基準が答えているのは、「手続きの形をした処理」か「状態そのものに意味がある処理」かの一点だけです。両方の性質を併せ持つ場合や、一回限りの手続きが後から外部に問い合わせられる対象へ変わっていく場合を、この基準だけでどう扱うかは、この章の範囲では答えが出ていません。確認できたのも、一つの手続き群における3+1の内訳だけであり、他の箇所で同じ比率になるとは限りません。境界線を引く基準は手に入りましたが、その手続きや状態がそもそも何の遷移を表しているのかという問いには、まだ答えていませんでした。
個別の対策を積み重ねてから根本原因に置き換える、という展開は、FSM以外の場所でも起きています。2026年8月、起動時のキー配送とエンジンスレッドへの通知経路を洗い直した際(ADR-102/105)、最初に固まったのは19件のコードレビュー指摘に対する、Opus(Claude Opusモデル)2体に互いの設計を批判させ合う敵対的レビュー・4ラウンドでした。OS所有マスク、フェーズ表、Altなりすましラッチの発火順序繰り上げなど、症状ごとに対策を積み重ねた設計で、それ自体は手堅くまとまっていました。
しかし著者が「もっと根本的に良い設計はないか」と問い直したことで、Opusによる根本原因分析が行われました。対策の大半——最もリスクが高い変更と評価されていたものを含みます——が、たった1つの構造的原因への対処で丸ごと不要になると分かりました。原因は、キーボードのフック処理を担当する、エンジンとは別の実行の流れ(ワーカースレッド)からエンジンスレッドへの通知に、PostThreadMessageWという関数を使っていたことです。この関数の宛先は、ウィンドウではなくスレッドID(実行の流れそのものを識別する番号)でした。

Windowsの標準的なメッセージ配送手順(DispatchMessageW)は、メッセージに付いたウィンドウハンドル(hwnd、生成したウィンドウを指す番号)を手がかりに、そのウィンドウ用に登録された処理関数を呼び出す仕組みです。ところがスレッドID宛のメッセージにはウィンドウハンドルが付きません。ウィンドウの無いメッセージに対してDispatchMessageWは何もしないため、これまでのawaseは、メッセージの中身を自前でmatch分岐する受け口をループの中に直接書いていました。この受け口は、メニューやダイアログの表示中には機能しません。表示中はWindows自身が持つ別のメッセージポンプ(ネストしたモーダルポンプ)が一時的に主導権を握り、このポンプは自前の受け口の存在を知らないからです。スレッドID宛のメッセージはこのポンプにも取り出されますが、DispatchMessageWが何もしないため、どこにも処理されずに消えます。取り出された記録は残らず、エラーも出ません。
解決策は、宛先をスレッドIDから、実在する専用ウィンドウのハンドル(hwnd)へ変えることでした。画面には表示されない「メッセージ専用ウィンドウ」(HWND_MESSAGEという特殊な親を持つウィンドウ)を起動直後に1つ作り、以後はそのhwnd宛にPostMessageWで送ります。自前のmatch分岐もやめ、このウィンドウ専用の処理関数(ウィンドウプロシージャ)として登録し直しました。DispatchMessageWは、メッセージのウィンドウハンドルから登録済みの処理関数を探して呼ぶ、という同じ手順を毎回踏みます。この手順は、呼び出し元が自分のループであっても、メニュー表示中のネストしたポンプであっても変わりません。ネストしたポンプは自分のアプリの都合を何も知らないままでも、OSの標準的な仕組みだけを頼りに、メッセージを正しく届けてくれます。
この根本原因が正しいという確信は、机上の議論ではなく的を絞った実験から得ています。ネストしたモーダルポンプの最中に150ミリ秒間隔でhwnd宛メッセージを60件送り、全件が欠落なく届くことを実機で確かめました。969行が消えた本章の経験と同じ形が、通知配送という別の場所でも起きたことになります。個別の症状に対策を積むほど、対策自体が複雑さの発生源になっていないかを、ときどき疑う必要があります。ただし今回実機で確かめられたのは、この配送保証という一点だけです。起動から動作までの通しのソークは、まだ行われていません。
他分野への転用
一直線の手続きと、問い合わせられる状態を分ける発想は、他の場所にもある
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として状態を外部から見える形のまま残すべきです。
第9章:エポック

第III部までで、awaseはWindowsやIMEが返してくる情報を型で区別し、フィルタを重ね、最後には経路そのものを断つことで守ってきました。それぞれの局面では、症状は一度収まったように見えています。しかし同じ種類の不具合は、名前を変えながら必ず戻ってきました。
第IV部は、この本の中で第9章だけからなる一部です。ここまで三部にわたって局所的に解決してきたはずの問題が、実は一つの共通する混同——一つのstateに複数の役割を同居させていたこと——の、別々の現れだったことが見えてきます。作用点、つまりawaseが実際に不可逆な操作を送り出す直前の一点に、この混同を閉じるための契約を置き直す章です。
実装は82分で終わりました。2026年7月4日、604cf99(06:58)・a0e4ec7(07:42)・008f039(08:20)という3つのコミットが、その日のうちに積み重ねられています。しかし、この82分を可能にしたのは、それ以前の3か月間、同じ問題に繰り返し失敗してきた記録でした。
「一箇所に集約すれば直る」という発想は、この本のここまでの章で、少なくとも2度、別の名前で試されています。第5章で見たBeliefStoreからshadow_modelへの改名が、その一つです。集約するたびに、症状は一度は収まったように見えました。しかし同じ種類の不具合は、形を変えて必ず戻ってきています。
3つのコミットの中身は、それぞれ役割が違います。604cf99は観測を受理する層そのものを新設し、a0e4ec7はその受理結果を型として保証したうえで読み出し側にepochフィルタを追加し、008f039はADR-077(Architecture Decision Record、設計判断とその理由を記録しておく文書)として記録を残しています。実装が短時間で済んだのは、何を作るべきかが3か月分の失敗を経てすでに明確だったからです。
本章の問いは一つです。一つのstateに、私たちは何を混同していたのか。そして、その混同をどう構造的に閉じたのか。
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)
この二つのコミットメッセージ自体が、単純なオン・オフ以上の複雑な事情を、すでに示唆しています。
ime_onは、その名の通り「いまIMEはオンか」を表すための、単純なbool値として出発しました。しかし、実際にコード上で読み書きされる場面を数えると、この一つの値は、少なくとも四つの異なる問いに答えるために使われていました。
第一に、ユーザーが親指キーを押して「日本語入力を始めたい」と意思表示したかどうかです。これはユーザー自身の頭の中にしかない情報で、awaseはキー入力という間接的な形でしか受け取れません。
第二に、フォーカス中のアプリケーションやWindows側が「いまIMEはオンだ」とどう報告してきたかです。これは複数の経路から、しかも互いに矛盾しうる形で届きます。
第三に、その報告を受けてawase側がIME切り替えのOS呼び出しを送り、まだ完了通知を待っている途中かどうかです。送った直後は、成功したのか失敗したのか、awase自身にもまだわかりません。
第四に、いまこの瞬間にキー入力をIME側へ回してよいか、それとも直接入力へ回すべきかという、後戻りできない選択をしてよいかどうかです。一度送出したキー入力は、取り消せません。
一つのboolに、意思・報告・進行中の変更・許可という、四つの異なる性質の情報を同居させていました。それぞれは更新される頻度も、信頼できる度合いも、失効するタイミングも違います。しかし読み書きする側からは、同じ一つのフィールドにしか見えていませんでした。
四つの役割を並べて見ると、混同の輪郭がはっきりします。

この混同は、抽象的な設計論としてではなく、具体的な分岐の増殖として姿を現します。ある呼び出しは「ユーザーの意思」を読みたいだけなのに、実際に手に取れるのは四つが混ざったime_onしかありません。呼び出し側は「いまはこの分岐を通っているはずだから、この値は意思のはずだ」という前提をコードの外に置いたまま値をそのまま使い、前提が崩れる場面――報告がまだ届いていないうちに次の意思表示が来た場合など――にだけ症状が現れました。たとえば古い観測がまだ有効だと誤って採用されれば、ユーザーが望んだ状態と実際にエンジンが動作するモードが食い違い、何も操作していないのに入力方式が急に変わったように見えます。原因を追うと、その値が「意思」なのか「報告」なのかがコードのどこにも書かれていないことに行き着きます。この一本の値は、ime_onという名前のまま、開発2日目から3か月以上にわたって存在し続けました。
四つの問いには、四つの層で答え直す必要があった
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とは、いまの状態と新しく届いたイベントを受け取り、次の状態を一箇所で決め直す処理のことです。実際、observer_pollは通常set_open_requestによって抑制される優先度上位の設計でしたが、focus_probeの後でset_open_requestが「消費済み」という扱いになると抑制する側がいなくなり、staleなobserver_pollの値がそのままbeliefを上書きしてしまいます。優先度という一本の軸だけでは、「誰が最終的に書き込んでよいか」という権限の問題を、あらゆる場面で表現しきれませんでした。
この整理から導かれたのが、意図・観測・遷移・障壁という四つの層です。層ごとに、答える問いは異なります。誰が書き込めるか、どの程度確実か、いつ失効するかも、層ごとに異なります。
| 層 | 答える問い | 誰が書くか | いつ失効するか |
|---|
| Intent(意図) | 何を実現したいか | ユーザー操作・awase自身の判断のみ | 次の意思表示があるまで有効 |
| Observation(観測) | 外部世界について何が観測されたか | 複数のprobe・pollが並行して書く | 観測した瞬間から古びていく |
| Transition(遷移) | 変更処理はどこまで進んでいるか | OSへの適用要求を出した経路 | 完了通知かタイムアウトで終わる |
| Barrier(障壁) | いま不可逆な作用を許してよいか | 他の三層を読んだ上で最後に一度だけ | 判断の都度、次の入力までしか有効でない |
四層の関係を図にすると、ObservationとIntentがそれぞれ独立にTransitionとBarrierへつながり、最後にBarrierだけが作用の可否を決める形が見えます。

実際のコードでは、優先度によって並んでいた五段階のソースが、そのままこの四層に対応します。sync_keyとphysical_keyはIntentへ、set_open_requestはTransitionへ、それぞれ再分類されました。focus_probeとobserver_pollはObservationへ、ctrl_bypass_holdのような一時隔離の仕組みはBarrierへ、同様に割り当てられています。
Intentを書き込めるのは、ユーザーの操作かawase自身の判断だけです。ADR-032の原則は、これを一字一句こう定めています。
UserIntentだけがdesired_openを即時に変えられる
Observerはdesired_openを直接壊さない――observer/モジュールからdesired_openへの代入を直接行わない。ImeEvent::ObserverReportedをdispatchしてreducerに判断させる
Observationは「観測した」という事実だけを運びます。それをIntentへ反映してよいかどうかの判断は、必ずreducerという一箇所へ集約するということです。四層のうち、ObservationとBarrierの関係が、この本を通じて繰り返し問うてきたことの核心です。観測したことと、いま実行してよいことは、同じではありません。
四層はまた、互いの内部表現を共有しません。Observationはどのprobeがどんな経路で観測したかという出所の情報を持ちますが、Intentが必要とするのは「ユーザーが何を望んでいるか」だけであり、観測がどこから来たかを混ぜて受け取れば、また責務は元へ戻ります。四つの箱を分けるということは、値を分けるだけでなく、それぞれの箱が知ってよい情報の範囲を分けるということでもあります。
原則を文書にまとめたことと、原則が実装で守られることは、別の作業でした。ADR-032が定義された後の6月30日から7月1日にかけて、21件の関連課題が一括で処理されています。そのうちの一つは、四層に分けたはずのImeBelief.input_modeが実際にはpubのまま公開されており、reducerを経由せずに外部から直接書き換えられる状態だったという指摘でした。四層に分けるという原則は文書の上ではすでに存在していましたが、コンパイラで強制する作業は別の日にちで改めて必要になったということです。
この2日間で見つかった問題は、公開範囲だけではありませんでした。一つは循環依存で、本来は下位のモジュールが上位のモジュールを知らないまま動くべきところ、下位側のengine::decisionが上位にあるはずのplatform::EffectOriginを参照しており、依存の向きが逆転していました。もう一つはGod Object、つまりPlatformStateやOutputのような一つの構造体に、複数の責務を抱え込ませすぎた状態です。最終的に、層をまたぐ禁止事項はdocs/layer-boundaries.mdへ、grep一発で違反箇所を洗い出せる形で書き出されています。
この整理では、二つの代替案が検討され、いずれも却下されています。
段階的リファクタを先延ばし→採用しなかった。循環依存は放置するほど絡まり、一度解消しないと次のリファクタのコストが指数的に増える。
God Objectを「整理」だけして分割しない→採用しなかった。責務の混在が根本原因であり、コメント整理では解決しない。
「後で分ければよい」も「コメントで整理すればよい」も、責務を型として分けることの代わりにはなりませんでした。原則を書くだけでなく、原則の違反を機械的に検出可能にするところまで踏み込んで、ようやく四層は維持され始めました。
この四層構造は、awase固有の発明ではありません。航空機のフライバイワイヤ(操縦桿の動きを電気信号でコンピュータに伝え、コンピュータが実際の舵面を動かす方式)には、よく似た四層がすでにあります。パイロットが操縦桿を倒す動作はIntent、速度計や姿勢センサーが返す値はObservation、舵面が実際に動いている途中の状態はTransitionにあたり、意図とセンサーの両方を読んだ上で失速につながる操作を最後に拒否する飛行制御コンピュータの保護則が、Barrierの役目です。
五つの決着を、同じ混同の解消として読み直す
ここまでの決着を、この四層に当てはめ直すと、それぞれが実は同じ種類の問題――四層のどれか一つを、専用の仕組みなしに済ませようとしていた問題――だったことが見えてきます。
| 章 | 当時の決着 | 四層での位置づけ |
|---|
| 第3章 | 固定待機からLiteralDetectFsmへ | Transitionの進行度を、時間の長さで代用していた |
| 第4章 | センチネル値からUnknown/Optimistic/Confirmedへ | Observationの不確実性を、型として表現し直した |
| 第5章 | BeliefStoreを経てshadow_modelへ集約 | Observation・Intent・作用履歴を一つの構造へ集めすぎた |
| 第7章 | フィルタ強化から経路遮断へ | Barrierの役割を、後段のイベント判定で代用していた |
| 第8章 | 増殖したFSM群からStepCoroへ | Transitionの表現形式を選び直しただけだった |
五つの章は、別々の問題を解いていたのではありません。同じ四層構造のどこか一つを、専用の仕組みなしに済ませようとして、繰り返しつまずいていました。事件の数だけ問題があったのではなく、一つの構造の中の四つの穴を、順番に踏み抜いていただけだったということです。実際、一つの層をどれだけ丁寧に作り込んでも別の層の混同は残ることは、第4章の型導入後も第5章・第7章の症状が解決しなかった経過が示しています。
この「一箇所に集約すれば直る」という発想そのものは、IME belief以外の場所でも独立に2回、同じ形で現れています。GJIの前置キー待機キューでは、複数のprobeがそれぞれ個別に保留中のキーを抱えており、probeを切り替えるたびに保留していたキーごと破棄される不具合がありました。解決は、単一の所有者(TsfWarmupCoordinator)へ保留状態を集約することでした。そして本章の主題である観測受理そのものも、7種のprobeがそれぞれ同じ判定ロジックを3箇所にコピーしていたという、同型の問題を抱えていました。一箇所に集約すれば直るはずだという期待が、集約点自体の中でまた責務を混ぜてしまう。単一プロセスの中に複数の非同期な情報源が同居する設計では、この繰り返しは偶然ではなく、構造的に起こりやすいと言えます。
しかし第5章が示した通り、四層に分けたという事実そのものは、7月4日の再発を防げませんでした。四層のどれもが、まだ「この観測はどの世界についての観測か」を表現できていなかったからです。
猶予時間は、また三箇所にコピーされた
7月4日のALT+TABバグに対して、最初に打たれた対策は時間による猶予でした。直前までshadow_onがオン相当で、かつ観測が届いてから200ミリ秒未満なら、その観測を疑わしいとみなします。この対策は、報告された症状を実際に抑え込みました。
当時、awaseがIME状態を把握するために動員していたプローブは、7種類に増えていました。TSF(Text Services Framework)とIMM32(Input Method Manager)という2つのAPI系統をまたぎ、同期・非同期・イベント駆動という異なる性質を持つプローブを並行して使う設計です(表中の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 | なし |
「信頼度」の列は、そのプローブが返す値をどこまで単独で信じてよいかを表しています。ImmCrossProbeが抱えていた問題は、信頼度の高さと観測が属する文脈の古さが別々の軸であるにもかかわらず、区別されずに扱われていたことです。実際、7種類のうち3種類がほぼ同じ条件式をそれぞれの場所にコピーしており、200ミリ秒という数字自体に根拠がなくCPU負荷が高い環境では簡単に超えてしまいます。同じ条件式が3箇所にある以上どれか1箇所を直しても残り2箇所は直らず、直ったのは症状であって、それを生んでいた構造ではありませんでした。必要だったのは、時間の長さを測ることではなく、観測がどの文脈に属していたかを直接識別することでした。
フォーカスの世代を数字にする
ここでいったん、「epoch」という言葉そのものを確認しておきます。epoch(エポック)は、この章では「区切りが変わるたびに1ずつ増える通し番号」という意味で使います。フォーカス(いまキー入力を受け取っているウィンドウ)が切り替わるたびに1つずつ増えていく番号だと考えれば十分です。時計のように時間の長さを測るのではなく、区切りが起きた回数だけを数えます。
そこで導入されたのが、FocusEpochという世代番号です。FocusStore::focus_epochというただのu64で、フォーカス先のプロセスが変わるたびにwrapping_add(1)で1つ増えます。
非同期プローブ(ImmCrossProbe・FocusProbe)は、起動する瞬間に、そのときのFocusEpochを1つだけ記録します。これがImmLikeTicketです。プローブが完了して結果を届けようとするとき、admit()がこのチケットに刻まれたepochと、いまの最新epochを突き合わせます。
記録するタイミングと、比較するタイミングは、意図的に分けられています。記録するのは観測を始めた瞬間、比較するのは観測が終わり結果を使おうとする瞬間です。この間にフォーカスが変わっていなければ、2つのepochは一致します。
ImmLikeTicketという名前が示す通り、この仕組みはImmCrossProbe専用ではありません。「起動時のフォーカスを覚えておき、完了時に照合する」という性質を持つプローブであれば、FocusProbeのように種類が違っても、同じチケットを共有できます。7種類あったプローブのうち、この性質を持つものだけが対象になり、GJIやTSF Observerのようにイベント駆動で届くプローブは、対象から外れています。
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を返します。_private: ()というフィールドが、admit()を経由しない限りこの型の値を組み立てられないようにしています。
admit()は、二つの数値を比べるだけの、副作用のない関数です。3か月の間に2度、同じ問題を別の名前で解決しようとして失敗してきたのは、この判定がコード中に散らばっていたことも一因でした。判定を一箇所の小さな関数へ閉じ込めたことで、以後プローブが増えても、admissionのロジックを個別に書き足す必要がなくなりました。
この時系列を図にすると、次のようになります。

拒否された観測は、ただ捨てられるだけではありません。REJECTED_EPOCH_MISMATCHというカウンタが1つ増えます。判定を1回の分岐で終わらせず、その結果を数え続けるところまで含めて、設計の一部になっています。
この型が実装された604cf99の変更行数は、わずか+111行、3ファイルでした。3箇所にコピーされていた時間ベースの推測を、小さな型を1つ足すことで置き換えられたことになります。行数の小ささは、何を1つの値として運ぶべきかさえ定まっていれば実装そのものは小さくて済む、ということを示しています。
受理は証明にすぎない、鮮度は使うたびに確かめる
AcceptedObservationという型の価値を、正確に言い当てる必要があります。この型が実現したのは、動的な検査を静的な検査に置き換えたことではなく、動的なepoch照合を一度通過したという事実を後段まで運べる値にしたことです。型によって鮮度が永久に保証される、という意味ではありません。型が保証できるのは、過去の一点についての事実だけです。「この値は、確かに一度admit()という関所を通った」という、構築時点の証明にとどまります。そこから先、その値が実際に使われるまでの間に世界がどう変わったかについて、型は何も語りません。
コンパイラは実行時にフォーカスが変わるかどうかを知り得ません。知り得ないものを、型だけで保証することはできません。AcceptedObservationが本当に置き換えたのは、「新鮮であるという保証」ではなく、「過去に一度、正しい手順で確認された」という、来歴の記録の仕方です。
観測が生まれてから使われるまでを一続きの流れとして並べると、観測の開始・観測の完了・観測の受理・信念への反映・効果の発行・効果の実行という六つの段階に分かれます。epochを照合できるのは、このうち「観測の完了」から「観測の受理」へ進む、ごく短い一区間だけです。区間の外――特に「効果の発行」から「効果の実行」までの間――に同じ照合があるかは、この節ではまだ問いません。
実際の設計は、次の3層で完成しています。
書き込み時: ImmLikeTicket::admit() → AcceptedObservation(型による証明)
ストア時: ImeObservation.focus_epochに記録(来歴の記録)
読み出し時: derive_open()がepochフィルタを再適用(実行時の再照合)
3層はそれぞれ、別の失敗を防ぐために存在します。書き込み時の型がなければ、admissionを経由しない値がどこかから紛れ込む経路を防げません。ストア時に世代を記録しなければ、observationがどの文脈で生まれたのかを、あとから参照する手段がなくなります。読み出し時の再照合がなければ、受理された時点では正しかった判断が、そのまま古くなった後も使われ続けます。
鮮度を最終的に守っているのは、型ではなく3層目です。derive_open()は、observationを読み出すたびに、記録済みのepochといまのepochを再び比較します。
let is_epoch_ok = |o: &ImeObservation| match o.source {
ObservationSource::ImmCrossProbe | ObservationSource::FocusProbe => {
o.focus_epoch == current_epoch
}
_ => true, // GJI/TSFはイベント駆動、ObserverPollは同期ポーリングのため対象外
};
この照合はすべてのプローブに及んでいるわけではありません。イベント駆動のGJI・TSF Observerは、いつ届くかをこちらが選べないためepoch照合になじまず、代わりに3000ミリ秒の鮮度ウィンドウで守られています。同期ポーリングのObserverPollも対象外ですが、こちらは500ミリ秒ごとに状態を取りに行くため、フォーカスが変わった後も古い値が返り続けるという不一致自体がそもそも起こらないからです。epoch導入によっても閉じ切っていないこの隙間が、どこまで安全かは第16章で扱います。
呼び出す側のeffective_open()は、呼ばれるたびに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)
}
一度受理したら終わり、という設計ではありません。使うたびに、いまの世界と照合し直す設計です。
似たような工夫は、日常のアプリにも隠れています。フードデリバリーの注文を思い浮かべてください。電波が悪いドライバーAへの割り当てが取り消され、ドライバーBが実際に商品を届けたあと、電波が戻ったAのスマホが自分がまだ担当だと思い込んだまま「配達完了」を報告してきたら、記録はどうなるでしょうか。これを防ぐ仕組みは単純です。注文を割り当てるたびに「今回は何巡目の割り当てか」という番号を振り、アプリは最新の巡目からの報告だけを受け付けます。古い巡目からの報告は、後から届いても無視されます。
この「巡目の番号を振り、古い巡目からの報告は無視する」という工夫自体は、awaseが発明したものではありません。Googleが2006年に発表した分散ロックサービスChubbyや、Kubernetesが持つresourceVersionという仕組み、複数の候補から一人のリーダーを選ぶRaftというアルゴリズムの「任期」番号は、いずれも同じ骨格を持っています。番号を振って古いものを捨てるという着想そのものに、awase固有の新しさはありません。
一方FocusEpochは、複数の主体を排除するための仕組みではありません。単一プロセスの中で、いまの自分が少し前の自分と同じ文脈にいるかどうかを識別するためのものです。フードデリバリーの例で言えば、複数のドライバーの誰が正しいかを決める話ではなく、同じドライバーのアプリが「さっき受けた注文と、いま受けた注文は同じ巡目か」を確かめているようなものです。競合する主体を1つに絞ることと、1つの主体の中で文脈の世代を区別することは、似た仕組みでありながら解いている問題が違います。
あえて工夫と呼べる点があるとすれば、番号を比べること自体ではなく、「番号が一致することを確認しない限り、そもそも値を受け取れない」という制約の掛け方です。駅の改札を思い浮かべてください。古い定期券でも一応通れてしまい、あとで駅員が記録を見て無効だったと気づくのと、そもそも改札のバーが開かず物理的に通れないのとでは、守り方の強さが違います。型は、不正な構築を防ぐ関所にすぎません。awaseの設計は後者で、admit()という関所を通さずにAcceptedObservationという値を作る経路は、通常のコードには残していません。
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という一つの領域の中で、具体的な型として実装したものです。カメラが対象を正しく捉えていても、その対象がミッションの前の段階に属するものであれば、いまの判断に使ってよい観測ではありません。経路変更の直前に取得した障害物の位置は、経路変更の直後には、もう存在しない前提に基づく情報になり得ます。センサーは嘘をついていません。ただ、もう関係のない世代についての情報を、正直に返しているだけです。
この考え方を一般化すると、次のような一続きの流れとして描けます。観測(RawObservation)→受理(epoch付き)→信念(WorldSnapshot)→提案された効果(ProposedEffect)→実行直前の再検証→ハードウェアコマンド、という順序をたどります。
awaseがこれまで実装してきたのは、この流れの前半です。観測が受理され、信念(WorldSnapshot相当のshadow_model)へ反映されるところまでは、epochという離散的な世代照合で守られています。信念から実際のハードウェアコマンド(awaseで言えばOSへのIME切替要求)へ至る経路の「実行直前の再検証」を、この章ではまだ何も述べていません。
著者が別に設計しているUnitree Go2 Runtimeというロボット向けランタイムにも、よく似た観測の型がすでに存在します。Observed<T>という、awaseより精緻に見える観測抽象化です。しかし、その先で一つだけ欠けている場所が見つかっています。詳細は付録にまとめてあります。その欠け方は、ここまで見てきた話と対をなしています。
ここまでの話が守っているのは、observationがbeliefへ届くまでの経路だけです。beliefが確定したあと、それを実際のOS操作(Effect)としてキューに積み、送信する経路については、まだ何も述べていません。ImmCrossの非同期送信は、一度呼び出されれば最後まで実行され、送信後に取り消すことはできません。
観測の経路には、書き込み時の型保証、ストア時の来歴記録、読み出し時の再照合という、3つの防御が重なっています。効果の経路については、まだ何も述べていません。
3か月かけて仮説を立て直し、82分でそれを型にした結果が、ここまでの仕組みです。古い観測は、拒否できるようになりました。しかし、古い観測からすでに作られたコマンドは、まだキューに残り得ます。エポックが閉じたのは、観測が意図を汚染するという入口の混同でした。実行した結果が意図をどう汚染しうるかという出口の混同は、まだ手つかずのまま残っています。
一つのstateに四つの役割を混同していたという問題は、型を分け、書き込める経路を絞り、世代を照合するという三段構えによって、構造的に閉じられました。しかし観測を受理する層を閉じたことは、実行した結果がどう扱われるかという、まったく別の層をまだ何も保証していないということでもあります。これから続く章が向き合うのは、その別の層で起きる出来事です。
この4層構造が、開/閉という一つの軸に限っても万能ではなかったことは、後になって分かりました。2026年8月、Win+Ctrl+→でTsfNativeアプリへフォーカスを移した直後、最弱の観測(conv推論、confidence中程度)だけを根拠にIMEが意図せずONになるバグが見つかりました(ADR-087)。同じconv観測値でも、実IMEがONの場合とOFFの場合がある、という対称的な2つの不具合は、信頼度の調整だけでは両立して解けません。ここから見えてきたのは、「engineの挙動決定に使うbelief」と「外部への書き込みを許可する根拠(warrant)」は別の型に分けるべきだ、という区別でした。beliefの誤りは訂正できますが、書き込みという不可逆な行為を許す根拠には、beliefより強い証拠を要求してよいはずです。時間軸・空間軸・トリガー軸に続く第四の軸として、この根拠軸が加わったことになります。純粋ロジックの部分は4608通りの網羅比較テストで固定されていますが、実配線と実機ソークはまだ済んでいません。
一つの確信度を判断と許可の両方に使い回した場合と、型を分けた場合の違いを並べると、次のようになります。

ここから引き出せるのは、AcceptedObservationが示した原則とは別の教訓です。確信度を一つの数値・一つの型で表し、その数値を「内部の判断に使ってよいか」と「外部への不可逆な作用を許可してよいか」の両方に使い回すと、この二つの基準が食い違う瞬間に必ずどちらかの方向へ壊れます。判断に使うbeliefと、不可逆な作用を許可するwarrantは、最初から別の型にしておくべきでした。
もう一つ、この4層モデルとは方向の異なる決着もありました。かな入力方式(charset)の状態については、awaseは特定の状態を予測してbelief化すること自体をやめ、config1.dbによる自動判定とベストエフォートの助言に切り替えています(ADR-091・094)。ある軸について、信念を正しく管理する型を作るのではなく、その軸の信念を持つこと自体を諦める、という選択肢です。ここで学べるのは、追跡する型を洗練させる方向だけでなく、「その軸のbeliefを持たない」という選択肢も設計の候補に入れておくべきだ、という判断基準です。外部の状態を確実に予測する手段がなく、誤って追跡したときの訂正コストの方が、追跡自体をやめるコストより高いなら、beliefを持たないことが最も安全な設計になり得ます。4層で全てを追跡することが、常に正解とは限りません。
設計原則
原則: 責務を型で分けることと、その型が指す文脈がいまも同じであることを保証することは、別の設計問題です。
適用条件: 一つの構造体やフィールドに複数の異なる時間軸・複数の書き込み主体からの情報が混在し、改名や再構成を繰り返しても同じ種類のバグが形を変えて戻ってくる場面に当てはまります。加えて、観測や処理が非同期に完了し、完了した時点で前提がすでに変わっているかもしれない場面にも適用します。
実装の形: まず、混在している役割を「誰が書くか」で分類し、役割ごとに別の型を用意します。書き込める経路を一つに絞り、その制約をprivateフィールドやアクセス制御によって、コンパイラやgrepで機械的に検出できるルールへ変換します。そのうえで、増加するだけの世代番号を用意し、観測や処理を開始する時点でいまの世代番号を記録します。値を実際に使う瞬間には、記録した世代といまの世代を必ずもう一度突き合わせます。
限界: 型を分けることは、責務が混ざらなくなることを保証するだけです。分けた型同士がどの文脈・どの世代の情報を指しているかは、型を分けただけでは何も保証しません。世代照合も、対象に含めていない経路(イベント駆動の観測など)には及ばず、観測をもとにすでに生成された作用が、送信される直前に古い文脈のまま実行されることは防げません。
第10章:送ったキーは、届いたのか
第IV部でawaseが閉じたのは、観測の入口でした。FocusEpochは、観測が生まれた瞬間の文脈を刻み、使われる瞬間にもう一度照合します。同じ値に見えても、刻まれた世代が違えば、もう同じものとして扱いません。
ここから第V部です。送った命令は、返ってくるとは限らない、という問いをこの部で扱います。awaseは、望ましい状態と観測を見比べ、ずれていれば実際にキーを送ったりIMM32を呼び出したりして、外の世界を書き換えます。送った命令は、いつ、どんな形で戻ってくるのでしょうか。あるいは、戻ってこないとしたら、awaseはそれをどう知ればよいのでしょうか。
送り出した結果を、いつまでの間、自分が送った当のものとして扱ってよいか。この時間をまたいだ同一視の問題は、観測の世代を区別する問題と、根は同じです。第10章と第11章は、その根を、作用という別の面から掘り下げます。第10章は送った命令の結果が観測へ返ってくるかどうかを、第11章は、まだ結果がわからない命令を、どこまでの範囲で安全に取り消せるかを扱います。
一つの関数が、この章の主役です。
state/platform_state.rsのcheck_drift_correctionは、「いまIMEを開いておくべきか」という望ましい状態(desired_open)と、直近の信頼できる観測(observations.most_recent_trusted())を見比べるだけの関数です。両者がずれていれば、runtime/ime_refresh.rsのir_apply_drift_correctionが呼ばれ、実際にVK送信かIMM32呼び出しでIMEの状態を書き換えます。動作を並べると、次の一本道になります。
desired_open(望ましい状態) ─┐
├─→ 比較 ─→ 乖離あり ─→ actuate(VK送信/IMM32呼び出し)
most_recent_trusted()(観測) ─┘
第9章まででawaseは、観測をいつのものとして信じてよいかという問いに答えを出しました。守れるようになったのは観測の側で、作用の側ではありません。この章が向き合うのはその先、書き換えた結果は観測へどう返ってくるのか、あるいは返ってこないのか、という問いです。「望ましい状態と観測を比べ、ずれていたら書き換える」というループ自体は目新しいものではなく、今回問題になったのはループの中身ではなく、それを当てはめた相手の方でした。
awaseが動くアプリは、観測できる能力がまったく違います。実際のIMM32呼び出しが素直に効くImmCross。ChromeやEdgeのように、そもそもIMM32での読み取りができないImm32Unavailable。Windows Terminalのように、実IMMクエリそのものを構造的にスキップするTsfNative。この3つのアプリ分類へ、awaseは同じcheck_drift_correction/ir_apply_drift_correctionのループを一律に適用していました。同じ薬を、体質のまったく違う3人の患者へ同じ量だけ処方したようなものです。ある患者にはよく効き、別の患者には効きすぎ、また別の患者にはまったく効きません。
3つのプロファイルが、どれだけ違う相手なのかを先に整理しておきます。
| プロファイル | 実IMM32読み取り | 実IMMクエリ |
|---|
| ImmCross | 可能 | 可能 |
| Imm32Unavailable(Chrome/Edge) | 不可 | 一部制限あり |
| TsfNative(Windows Terminal等) | 不可 | 構造的にスキップ |
この表から読み取れるのは、「読み取れるかどうか」が二択ではなく段階的に違うという点です。同じ1本のループを当てはめれば読み取れる側は機能し、読み取れない側のどこかで必ず無理が出るため、この章では効きすぎた患者とまったく効かなかった患者という、2つの正反対の壊れ方を通じて問いを立てていきます。
自分の誤読を、自分で本物にしてしまった
2026年7月8日、実機ログにこんな記録が残っています。Chrome上のGJI(Google Japanese Input、Google 日本語入力)で、ユーザーが「これでいいかな」と入力したところ、3通りの壊れ方が起きました。全部がカタカナになる「コレデイイカナ」。先頭の"k"だけがローマ字のまま残る「kおれでいいかな」。先頭の"ko"だけが残る「koれでいいかな」。ユーザーは同じ単語を、壊れるたびに何度も打ち直していました。
タイムラインを追うと、発端は入力そのものではありませんでした。同じ実機ログの05:01:26前後には、FocusChange [20408→9668→20984]という記録があります。Chromeのメインコンテンツと、GJIの候補ポップアップウィンドウとの間で、ウィンドウハンドルが3回往復していました。その28秒後、05:01:54.387に[conv-mode] Hiragana/roma → ZenKata/roma (conv=0x0000001B)というログが出ています。ユーザーは何も操作していません。conv modeが誤ってカタカナへ切り替わったこと自体はログから確実ですが、初回トリガーは未解明です。GetForegroundWindow()が候補ポップアップ側を一瞬指し、誤ったconv値を拾ったという説明は調査時点の仮説にすぎず、28秒という間隔を考えると根拠は弱いままです。確かなのは、一過性の誤読が実際に一度は起きた、という一点だけです。
厄介だったのはその先です。state/conv_mode.rsのConvModeMgrは、この一発の誤読を即座に確定させます。そして次に走るウォームアップ処理(tsf/warmup/cold_warmup.rsのpreamble())が、awase自身がいま信じているconv値を見て、VK_DBE_KATAKANAのようなキーを実際にGJIへ送信してしまいます。このウォームアップ自体は本来必要な処理ですが、それが確定していないはずの一発の誤読を勝手に確定させる経路にもなっていました。一過性の誤読が、GJIの本当の状態としてロックインされ、その後はraw conv読み取りが「本当にカタカナになったGJI」を正しく反映し続けるため、単なる誤読では済まなくなります。
awase自身の復旧・ウォームアップ機構が、誤った信念を実世界へ繰り返し書き戻す、自己参照ループになっていました。調査を担ったADR-078は、この構造をこう総括しています。「awase自身の復元機能が誤ったbeliefをcold warmupのたびにreal IMEへ再書き込みし続ける自己参照ループである」。同種の書き戻しはkey_pipeline.rsやprobe_io.rsにも見つかっており、単発の欠陥ではありませんでした。
この増幅ループに対しては、ADR-078という設計判断が提案されています。撤去するだけの最小対策(ConvModeMgr::needs_conv_restore_write/mark_conv_restore_written)は、Phase 1aとして2026年7月9日に一度は実装されましたが、いまはもうコードに残っていません。後に第13章で扱う大規模な削除の巻き添えを受け、dead codeとして撤去されています。DesiredMode/EffectiveMode/ModeConstraintという全体の型設計も、いまだ提案の段階のまま一度も実装されていません。この小さな決着がなぜ消えたのかは、第13章であらためて向き合います。
補正が、二度と発火しなくなった
2026年7月22日、Chrome+GJIでタイピングしていたユーザーは、奇妙なバックスペースに気づきました。ログにはgiving up, backs=1 cleanup only (no re-send)という記録が4連続で出力され、ローマ字の再送を伴わない単発のbackspaceだけで後始末されていました。不審だったのは、その間ずっと[drift]という補正のログが一度も出ていなかったことです。
Chromeは、実際のIMM32読み取りができないImm32Unavailableに分類されるアプリです。そこでawaseは、runtime/key_pipeline.rsのapply_focus_probeという関数の中で、代わりの手段を使っていました。shadow_on = self.platform_state.ime.effective_open()、つまりactuateした値、awase自身の信念そのものを、write_focus_probeという経路を通じて低信頼度(confidence=Low)の観測としてストアに書き戻します。コードのコメントには「shadowのapply値を代替観測として記録」とだけ書かれていました。
この経路は、定義上observed == desiredという状態を自作します。書き込んだ値をそのまま読み返しているだけなので、乖離が生まれる余地がありません。most_recent_trusted()は信頼度の下限を設けておらず、ほかに観測が無ければこの低信頼度の観測がそのまま採用されます。check_drift_correctionは、乖離を一度も検知できませんでした。補正は、構造的に一度も発火し得なかったのです。
「成功したかのように見える」のではありません。「成功したことにした」のです。観測を偽装すると、その後どれだけ実際の状態がずれても、二度と気づけなくなります。壊れた体温計が、壊れる直前の測定値を、いまの体温だと言い張り続けるようなものです。復旧の仕組みが、自分自身の目を潰していました。
2026年7月22日に投入された修正は、この章の主題であるActuationの型設計とは別系統でした。per-VK confirmのgive-up分岐から、send_chrome_gji_reinit_and_pollというGJI再初期化処理を直接呼ぶ応急的なパスで、この関数自体は既存の別目的の実装を呼び出しに追加しただけでした。観測ストアを経由してcheck_drift_correctionに本物の観測を与える設計も検討されましたが、400ミリ秒の待機と新しい観測ソースの追加が必要になることを理由に見送られています。
この応急パスが安全なのは、send_chrome_gji_reinit_and_pollが送るのがVK_KANJIのようなトグルキーではなく、ON/OFF専用の冪等キー(VK_IME_ON/VK_IME_OFF)だからです。もし推測が外れて実際にはすでにONだったとしても、最終的な到達状態は変わらずONのままで、トグルキーなら二重反転でOFFに固定されかねないところを、冪等な操作を選ぶことで回避していました。
ADR-080は、この非対称を隠さず明記しています。ADR-080が構造的に閉じられるのは次の節で扱う壊れ方だけで、この節で見た観測を書き込む側の欠陥は、actuation側の型変更だけでは塞がれません。片方は型で防げるが、もう片方はまだ防げていません。
675ミリ秒の間に、同じキーを16回送り続けた
2026年7月25日午前1時28分31.022秒から31.697秒まで、わずか675ミリ秒の間に、あるログが16回連続で出力されました。[drift] correction: observed=true ≠ desired=falseという乖離の検知と、VK_IME_OFFの実送信が、平均およそ45ミリ秒間隔で繰り返されていました。これはobserve tickの20ミリ秒タイマーとほぼ同期する間隔です。ユーザーは画面上で、キーが連打されたかのような挙動を観測しています。ログのduration_msは補正のたびにリセットされず、84502ミリ秒から85176ミリ秒へと単調に増え続けていました。乖離が一度も解消されないまま、補正だけが空振りし続けていたことがわかります。
舞台はWindows Terminalでした。フォアグラウンドのクラスはCASCADIA_HOSTING_WINDOW_CLASSですが、実際にフォーカスを持つのはWindows.UI.Input.InputSite.WindowClassという別のウィンドウで、force-tsf判定によってTsfNative扱いされます。原因は前節の壊れ方とは正反対の場所にありました。actuationの結果はon_ime_apply_completeが受け取りますが、generationがNoneであるため観測ストアを更新するイベントがdispatchされず、「Skipping IMM query for known-broken class」というログの通り実IMMクエリ自体も行われません。観測を更新する手段はほかにどこにも残されておらず、乖離は正しく検知され続けるのに、actuationの結果が観測へ一切フィードバックされないため、収束する手段がどこにも存在しなかったのです。
さらに、desiredとlast_intentが一致している場合、通常の再送閾値(DRIFT_CORRECTION_THRESHOLD_MS、400ミリ秒)ではなく即時(0ミリ秒)で再送する高速パスがありました。本来は素早い収束を助ける設計ですが、収束する見込みが無い状況では、observe tickのたびに無条件で同じVKを再送する無限ループとして働いてしまいます。送信のたびにcomposition] marked cold reason=SetOpenFalseというログも出ており、warm状態が毎回破棄され、次の文字出力のたびにVK_DBE_HIRAGANAのウォームアップが強制される計算になります。
この2つの壊れ方の対比は、ADR-080自身がそのまま言い当てています。前節は観測を書き戻して乖離を消し、収束を偽装しました。この節は逆に、actuateした結果が観測に一切返ってこず、収束を一度も記録できませんでした。同じ欠落を、正反対の側から踏んだ2つのバグでした。2つの経路を並べて見ると、この対称性がそのまま見えてきます。

どちらの経路も、actuateした結果を観測へ正しく返すという一段が欠けています。Chromeでは自分の意図をそのまま観測として書き戻し、Windows Terminalでは観測を一切更新しません。片方は乖離を消しすぎ、片方は乖離を消せない、という正反対の結果に見えても、原因は1つでした。
暫定的な修正は、直前に同じdesiredへ補正を送ってから一定時間はスキップする、last_drift_correction_sendという手作りのクールダウン変数でした。後でわかる通り、これは単独の発明ではなく、似た目的の変数がそれまでにも独立して複数箇所に生まれていました。
同じ欠落を、安全側と活性側それぞれから踏んでいた
増幅、偽装収束、無限再送。3つの壊れ方を並べ直すと、actuateした結果を観測にどう返すか、という設計判断がどの経路にも一貫して存在していなかったという、共通する欠落が見えてきます。この欠落を裏づけるように、actuationループを自分自身で止められないという問題への場当たり的な対策が、少なくとも4箇所で互いに無関係に独立実装されていました。
| 変数 | 抑えていた対象 | カテゴリ |
|---|
Output::last_gji_reinit_ms | GJI再初期化のgive-up連続発火 | actuationの終端 |
Runtime::last_drift_correction_send | drift correctionの再送 | actuationの終端 |
PlatformState::focus_debounce_ms | フォーカス変更直後の反応 | 観測側のちらつき |
ImeKindDebounce | IME種別判定のちらつき | 観測側のちらつき |
上の2行と下の2行は別のカテゴリで、この章の主題と直接関係するのは、actuationの終端を止める前者2つだけです。散らばったbooleanフラグはFSMに吸収できるというdocs/adr/index.mdの教訓と同じく、散らばったcooldown timestampは型付きトランザクションに吸収できます。
ここまでを一段抽象化すると、次のように言えます。対象を読み戻せるかどうかが、ループの設計を決めます。決めるのは実装者の好みではなく、対象システムの観測可能性そのものです。読み戻せるならclosed-loop(確認してから次に進む)、読み戻せないならbounded blind retryと明示的な諦め、の二択しかありません。両者を同じ汎用ループでカバーしようとすると、読み戻せない側でclosed-loop前提のコードが誤動作する形で、必ずどこかにしわ寄せが出ます。
もう1つ、踏切の遮断機を思い浮かべてください。電車が来ていないのに遮断機が下りたままにならないことと、電車が通過したら必ず一定時間内に上がることとは、別々に保証すべき性質の異なる約束です。前者が崩れれば渋滞が起き、後者が崩れれば安全そのものが崩れます。これは実は、分散システムやリアルタイム制御の分野でsafety(安全性)とliveness(活性)と呼ばれる区別と同じ骨格で、awaseで言えば、型・dylint・architecture guardが守る安全性があっても、補正が必ず有限回で収束するという活性は自動的には守られません。前節までの2つの壊れ方は、同一の欠落をsafety/livenessそれぞれの側から踏んだものだった、という整理です。
この整理は、Claude Fable 5との壁打ちで得られたフレーミングと、実装を書いたSonnetの当初の見立てとが補い合った結果でもあり、こうした協働の形は、開発プロセスを主題にする第13章であらためて取り上げます。
FeedbackPolicyを、省略できない型にする
採用した解決は、FeedbackPolicyという型でした。
enum FeedbackPolicy {
Read { source: ObservationSource, deadline: Duration },
Blind { max_attempts: u32, backoff: Duration },
}
読み戻せる対象にはReadを、読み戻せない対象にはBlindを割り当て、新しいactuation経路を追加するときこの指定を省略するとコンパイルが通りません。2つの分岐がどこへ帰結するかは、次のように整理できます。
Read → 実観測を確認 → 一致した → Confirmed
→ deadline超過 → (収束を確認できないまま終了)
Blind → 試行を送信 → max_attempts未満 → 再試行
→ max_attempts到達 → GaveUp(観測ストアには書き込まない)
この設計を貫くのは、成功を偽装しない、しかし失敗も握りつぶさない、という思想です。進行中の試行はConfirmed(確認できた)かGaveUp(有界回数を尽くして諦めた)のいずれかに帰結し、GaveUpは観測ストアに一切書き込みません。書き込めば前節までの壊れ方が再発するからで、成功・失敗・不明という3値を明示的に扱っていることになります。
もう1つ、見落としがちな点がありました。この試行の状態を、observe tickのたびに新しく作り直してはいけません。check_drift_correctionはnowと観測だけから毎回計算する純粋関数で、過去に諦めた記憶を持たないため、そのまま流用するとtickごとに新しい試行を構築しmax_attemptsが実質的にリセットされ、前節で見た無限再送が型を被って再発します。試行の状態を誰がどこで保持するのかという記述が欠けている、というCodexの指摘を受け、所有者をRuntimeとし、desired_openが変化したとき、フォーカスが変わったとき、ConfirmedかGaveUpが確定したときにのみ破棄して作り直す設計に直しました。
この2分岐にたどり着くまでには、もう1つの案がありました。当初はRead/Blindに加えて、「読み戻せないが一定時間後は効いたはずとみなす」第3の分岐(AssumeAfter)も設ける予定でしたが、これを撤回させたのもCodexのレビューでした。Imm32Unavailableにも、開閉状態を裏づける信頼できる観測源はそもそも存在しない、という指摘です。GJIのI/Oカウンタからの間接観測はinput_mode判定専用で、開閉の観測ストアには最初から書き込まれない設計だったのです。「一定時間後は確定とみなす」という判定を観測への書き込みに横流しすれば、前節で見た偽装収束がそのまま再発しかねません。AssumeAfterは分岐として廃止され、Imm32UnavailableもBlindに統合されました。
さらに大きな案として、読み戻せるかどうかで振る舞いを分けるという発想を型でなく実装そのものの分離にまで徹底し、3つのプロファイルごとに専用のコントローラを完全に分離する設計も検討されていました。最も設計として正直な形ですが、check_drift_correctionを3通りに分岐・重複実装するコストと、プロファイルごとに挙動が乖離していくリグレッションのリスクが大きく、今回は見送られています。FeedbackPolicyをデータとして渡す今回の方式は、この完全分離案が持つ利点の大半を、より小さい変更で得るための選択でした。
もう一つ、正反対の方向に振り切った案もありました。継続的な照合ループそのものを全廃し、全プロファイルをイベント駆動のactuationへ置き換える案です。現在の20ミリ秒間隔のループが、ImmCross側の実挙動を含めて実際に何を救っているのかを、まだ棚卸しできていません。棚卸しをしないまま廃止すれば、退行のリスクを読み切れないため、この案も今回は見送りました。
実装の途中で、もう1つ訂正が入りました。当初、GaveUpから復旧する条件は、targetと異なる値の観測が来たら、と設計していました。しかしIMEの開閉のようなbool値は、間違った値が1通りしかありません。乖離が続く限り、この条件はほぼ毎tick真になり、GaveUpをその場で無効化してしまいます。実装している最中に、この欠陥に気づきました。正しい復旧のシグナルは、観測の値ではなく鮮度です。GaveUpした時刻より後に、何らかの新しい観測が記録されたかどうかだけを見ます。これは第9章のエポックが扱った、観測の鮮度という主題と、そのまま地続きです。
試行状態の所有者の欠落とAssumeAfterの危うさは、どちらも実装が固まった後、read-onlyでレビューしたCodexが指摘して初めて拾われたものでした。AIの役割分担がどう効いたかは、第13章であらためて扱います。
単一の窓口へすべてのactuation呼び出しを統合する作業はまだ終わっておらず、ir_apply_drift_correction自身やplatform.rs/runtime/mod.rsのforce-on経路など既知5箇所の呼び出しは、代わりにcrate全体の呼び出し箇所数を凍結するテストで監視しています。
この設計はWindows実機でのソークテストをまだ実施しておらず、IME_ACTUATION_BLIND_MAX_ATTEMPTS = 5という試行回数の上限も裏付けのない暫定値です。
GaveUpとその後の扱いには、この章を書き終えた後も続きがありました。2026年8月、GJI再初期化(reinit)の完了後にromajiを再送するという案が一度検討されましたが、「完了を知らせる通知の経路がそもそも存在しない」という理由で却下されています(ADR-100)。却下と同時に、診断用のjournal記録という代替策だけは残されました。その後ADR-101は、却下の理由だった完了通知・フォーカス世代の照合・送信後処理・順序保証という4つの欠落を先に埋めたうえで、同じ発想のretryをあらためて実装しています。フォーカス世代を照合してから再送する仕組みは、次章で見るフェンシングトークンの空間版にあたり、遅れて届いた古いフォーカス先へ再送してしまう事故を防ぎます。設計文書どおりに実装したコードへの別レビューで、2件の実装ミスが見つかったという記録も残っています。設計と実装の間には、レビューという別の規律がなお必要でした。
この経緯からは、この章のFeedbackPolicyとは別の、開発プロセスそのものについての原則が引き出せます。一度却下した設計を、却下理由を潰さないまま再提案すれば、同じ議論を繰り返すだけです。却下理由を、二度と検討しないための蓋としてではなく、満たすべき条件のチェックリストとして残しておけば、条件が揃った時点で同じ設計を安全に採用し直せます。この再送についても、Windows実機での検証はまだ済んでいません。
この章が答えたのは、actuateした結果を観測にどう返すか、という問いでした。しかし、観測へ返ってきた証拠が本当にいま実行している試行の手柄なのか、という問いにはまだ答えていません。遅れて届いた確認が別の試行のものだった場合、awaseはそれをどう見分けるのでしょうか。
Physical AIへの接続
押せなかった、という事実も記録しなければならない
ロボット掃除機が、ドアを押して開けようとしている場面を想像してください。実際には押せていないのに、押せたことにして次のタスクへ進んでしまえば、ドアの前で同じ動作を延々と繰り返すか、逆に押せなかった場所を素通りしてしまいます。かといって、押せなかったときに何度も同じ力で押し続けるのも危険です。ロボットに必要なのは、押せたかどうかを確認できるなら確認してから次へ進み、確認できないなら有限回試して、押せなかったという事実を記録した上で諦める、という振る舞いです。洗濯機が蓋を閉めたつもりで、実際にはロックが掛かっていない場合も同じ構造です。閉まったことにして運転を始めるのは危険であり、かといって閉まるまで際限なく力をかけ続けるわけにもいきません。
これは実は、制御工学でclosed-loop制御/open-loop制御と呼ばれる区別と、同じ骨格を持っています。ただし本書がここで扱っているのは、対象を読み戻せるか否かという二値の設計判断そのものであり、制御工学が扱うゲイン設計・むだ時間補償・PID制御のような連続的な調整技法とは、水準が異なります。本書で「開/閉ループ」という言葉を使うときは、常にこの狭い意味に限定されています。
この区別が難しいのは、同じロボットの中でも、部位やセンサー構成によって「読み戻せるか」が一様ではない点です。関節の角度はエンコーダで確認できても、外界に対して実際に力が伝わったかどうかは、専用のセンサーが無ければ確認できません。1台のロボットの中に、Readで扱うべき対象とBlindで扱うべき対象が混在するとすれば、それはこの章でawaseが3つのアプリ分類へ同じループを当てはめてしまったのと、構造的には同じ状況です。
著者が別に取り組んでいるUnitree Go2 Runtime(ロボット向けアプリケーションランタイム)にも、自分のactuatorが起こした結果を読み戻せない場合にどう有界に振る舞うべきか、という同種の設計判断があります。この話は、終章で改めて短く触れます。
設計原則
原則: 対象を読み戻せるかどうかが、補正ループの設計を決めます。読み戻せるなら確認してから次に進み、読み戻せないなら有界な試行で明示的に諦めます。
適用条件: 望ましい状態と観測を比較して自動的に是正するループを、観測能力の異なる複数の対象へ一律に適用しようとするときに当てはまります。
実装の形: 諦め(GaveUp)を型で表現し、諦めた事実を観測ストアに書き込ませません。復旧条件は値の一致ではなく、証拠の鮮度で判定します。新しい経路を追加するときは、この方針の指定を省略できない型にします。
限界: 観測を書き込む側の偽装は、actuate側の型設計だけでは防げません。保証しているのはあくまで、有限回で終端すること(liveness)であり、状態が正しいこと(safety)を保証する仕組みとは別に用意する必要があります。加えて、この設計自体がWindows実機での検証を済ませていないことも、ここに記しておきます。
第11章:消したのは、疑わしい文字ではなかった

実際に壊れていた時間は、0.4秒にも届きませんでした。ログのタイムスタンプを並べると、最初のキー送信から最後の確定まで、わずか377ミリ秒しかありません。ところが、この377ミリ秒の中で何が起きたのかを正しく言い当てるまでには、何日もかかりました。短い時間に詰め込まれた出来事ほど、後から読み解くのが難しくなる、という一例です。
「41分」と入力したのに、「4分」になった
舞台はWindows Terminalでした。CASCADIA_HOSTING_WINDOW_CLASSというウィンドウクラスの上で、GJI(Google Japanese Input、Google 日本語入力)がTSF(Text Services Framework)経由でネイティブに動いている環境です。この環境は、1文字ずつ送信し、1文字ずつ確定の証拠を待つ「per-VK confirm」という経路を使っていました。1文字ごとに賭けをやり直す分、化けたときの被害を早く食い止められる、という設計でした。
2026年7月22日、実機でこんな操作をしました。Ctrl+無変換でIMEをOFFにし、半角の4と1を直接パススルーで入力します。続けて物理サムキーでIMEを再びONにし、ローマ字で「ふん」(fu+nn)を高速に連続入力しました。時刻を書き留めるつもりの、ごくありふれた操作です。「41分」と表示されるはずでした。ところが実際に画面に残ったのは、「4分」でした。1が消えていたのです。
この消え方には、直感に反する点がありました。もし化けるとしたら、直前に入力していた「ふん」の合成が壊れて、fやuが変な形で残るはずです。しかし実際に消えたのは、「ふん」とは何の関係もない、IME OFF中に別のスコープですでに確定していた実文字1でした。疑わしいと判定されていたのはuのほうであって、1ではありません。疑ってすらいなかった文字が、代わりに消えていたのです。
ログのタイムスタンプを追うと、377ミリ秒の間に、少なくとも4つの意味のある出来事が起きていたことが分かります。
| 時刻 | 出来事 |
|---|
| 29.058 | romaji "fu" をcold=263として送信開始 |
| 29.171 | 300ms deadline超過、uをsuspected literalと誤判定 |
| 29.388〜29.392 | backspace×1実行、"fu"をcold=264として再送 |
| 29.429〜29.435 | 候補ウィンドウSHOW(#892)を根拠にcold=264のVK0が確定 |
「41分」という文字列そのものには、特別な意味はありません。作業の区切りを書き留める、ふだんの入力です。特殊な操作のときにだけ壊れるなら避けようもありますが、今回はIMEのON/OFFを切り替えながら数字と日本語を混在させて打つという、誰もが日常的に行う操作の中で起きていました。
第9章で確立した「エポック」――観測はそれが得られた瞬間でなく、指し示す対象の状態で評価すべきだという考え方――は、ここでも通用するはずでした。ところがこの4つの出来事を時系列に並べただけでは、1がなぜ消えたのかは分からず、原因はこの表のどこにも直接は書かれていません。何が起きていたのかを突き止めるには、もう一段掘り下げる必要がありました。
証拠が届けば、それは今の試行のものだと思っていた
per-VK confirmの設計は、こうなっていました。1文字送信するたびに、確定の証拠――候補ウィンドウのSHOWイベント、あるいはGJIプロセスのWrite I/Oカウントの増加――を待ちます。300ミリ秒のdeadline内にその証拠が届けば、その文字は無事に合成されたと確定します。届かなければ、「合成に失敗した、疑わしい文字だ」と判定し、backspaceで消してから同じ文字列を再送します。この設計自体は、第3章の「賭けに頼らず検出する」という発想の延長でした。
この設計は、証拠が届けば、それは今まさに送ったVKに対する証拠のはずだという前提を暗黙に置いていました。候補ウィンドウのSHOWも、GJIのWrite I/Oも非同期に届くシグナルにすぎず、それを引き起こした送信試行との対応関係を時刻で確認する仕組みは、当時どこにもありませんでした。
1が消えた理由の仮説は、3つ考えられました。1つ目は、GJI自体が本当に合成に失敗していたという、当初の判定どおりの仮説です。この場合、システムの判断は正しく、バグは別の場所にあることになります。2つ目は、300ミリ秒のdeadlineが短すぎただけで、GJIの合成自体は実は成功していたという仮説です。いわばタイミングの空振り(timing false positive)で、判定そのものが誤りだったことになります。3つ目は、backspaceが消す対象そのものを取り違えていたという仮説です。判定は正しくても、その判定への反応の仕方に誤りがあった可能性です。
この3つを見分ける鍵は、last_gji_write=360ms agoという、warm/cold判定のために元々ログへ出力されていた1行のフィールドにありました。診断のために新しく足したものではなく、ふだんから残していた観測記録が、想定していなかった問いにまで答えてくれた形です。
なお、300ミリ秒というdeadlineの値自体は、この章で新たに調整した数字ではなく、過去の章で繰り返し登場した実測に基づくタイミング定数です。deadlineを動かして症状を消す道は、この章ではあえて選びませんでした。
360ミリ秒を引くと、証拠は前の試行のものだった
この章の「実験」は、新しいコードを書いて試すことではありません。すでにあるログの1フィールドを、手元で逆算するというホワイトボード上の作業でした。
候補ウィンドウがSHOWした時刻は29.429です。ここからlast_gji_write=360ms agoを引くと、29.429 - 0.360 ≈ 29.069になります。この値は、cold=263が送信したfのWrite I/O時刻である29.065と、ほぼ一致します。一方、cold=264の再送が始まったのは29.388で、29.069より後です。29.429のSHOWイベントが指し示す先は、2回目の試行ではなく、1回目の試行のほうでした。
つまり、29.429のSHOWイベントは、見捨てたはずの1回目(cold=263)のGJI I/Oが遅れて反映されたものであり、2回目(cold=264)の送信結果ではありませんでした。1回目の「fu」の合成は、実は最初から成功していたのです。300ミリ秒のdeadlineに対して、候補ウィンドウの表示がおよそ41ミリ秒遅れただけで、GJI自体は正常に動いていました。仮説2、タイミングの空振りが正解でした。
この逆算の作業自体は、特別な道具を必要としませんでした。すでに残っていたログを、電卓で引き算するだけです。難しかったのは計算そのものではなく、「候補ウィンドウのSHOWは、いま送った文字に対する返事だ」という思い込みを脇に置いて、時刻という一次データだけを信じ直すことでした。
検証はもう一段続きます。cold=264自身のconfirm(29.435、per-VK[0/1] confirmed vk=0x46)も、調べると同じSHOWイベント#892に便乗して確定していました。世代をまたいだ証拠の使い回しは、backspaceの誤発火とconfirmの誤帰属の2回起きていたことになります。今回はたまたま最終的な出力(「分」)自体は正しく収束したため一見無害に見えますが、ADR-079自身が明言しているとおり設計上の偶然にすぎず、構造的な穴がふさがったわけではありませんでした。
似た問題は、awase以外の場所でも繰り返し見つかっています。フードデリバリーの配達を思い浮かべてください。電波の悪い場所を通った配達員が、実際にはとうに配達を終えていたのに、「配達完了」の報告だけが遅れてサーバーに届くことがあります。もしこの報告を、たまたまそのとき進行中だった別の注文の完了報告だと勘違いしてしまえば、本当の注文はいつまでも「未完了」のままになりかねません。届いた事実そのものは正しくても、それがどの注文に属するかを確かめなければ、意味を取り違えます。この構図は、分散システムの理論でも古くから知られています。Chandra-Toueg(1996)は、非同期システムでは、誤検出しないことと、有限時間で必ず検出することを同時には満たせないと示しました。二つとも、「非同期に遅れて届く証拠は、それ単体ではどの試行のものか判定できない」という同じ形の問題でした。
VK_BACKは、狙って消す命令ではなかった
証拠の逆算から見えてきた本質は、証拠の新しさだけでは足りない、ということでした。「最新の観測か」を確かめるだけでは、遅れて届いた証拠がどの試行に属するかまでは分かりません。世代を明示的に紐付けなければ、別の試行の成功を、今の試行の手柄として誤って採用してしまいます。これが、この章のタイトルへの答えです。backspaceが消したのは、疑わしいと判定されたuではありませんでした。そもそも消すべき対象が最初から存在しない場所に割り込んで、無関係な確定済み文字を消したのです。
この誤りは、もう一段深いところに、取り消し操作そのものの性質への気づきを含んでいました。ADR-079は、その気づきをこう書き残しています。
VK_BACKは「疑わしい文字を狙って消す」命令ではなく、カーソル直前の1文字を無条件に消す命令である。消すべきliteralが存在しない以上、backspaceは必然的にその手前の唯一実在する確定済み文字を消す。
狙いを外したときに「外れて終わる」操作ではなく、「外れても何かを壊す」操作だったのです。ここに気づくまで、backspaceは「疑わしい文字を狙い撃ちする道具」だと思い込んでいました。実際には、狙いなど最初から持たない、カーソル位置だけを頼りにする鈍器でした。
同じ「取り消す」という目的を持つ操作でも、ESCキーは事情が違います。Windows APIの仕様上、ESCが破棄できる範囲は「現在IME ONになっているcomposition」に限られており、それより前に別のスコープで確定していたテキストにまでは届きません。VK_BACKが持たなかった構造的な境界を、ESCはあらかじめ持っていたことになります。同じ「消す」でも、何を消せるかの範囲があらかじめ決まっている操作と、決まっていない操作とでは、誤って使ったときの結果がまるで違いました。
この気づきは、精度を上げて当てにいく方向の対策――deadlineを伸ばす、判定条件を精緻化する――にも、同じ限界があることを示していました。当てられる保証がない以上、外れたときの被害を設計そのもので抑えるしかありません。似た構図は、フィルタを重ねても勝てなかった第7章にも通じます。あちらは「何を検知するか」という入口側の限界、こちらは「検知した後、何を取り消すか」という出口側の限界という違いはありますが、境界を守れなかった教訓を検知と復旧の両面から見せる対になっています。
もう一つ、この章で目についたのは、既存の資産をそのまま転用できたという実装上の軽さでした。第9章の「エポック」は、それまで「actuationがどの世代の意図に基づくか」を区別するためのものでしたが、ここでは「confirm証拠がどの世代の送信に属するか」という新しい適用先に変わっただけで、新しい概念は必要なく、cold=Nという既存のカウンタをそのまま流用できました。一度手に入れた道具は、最初に想定していなかった場所でも使い回せることがあります。
検出は塞いだ、回収の設計はまだ実装していない
この発見をもとに、ADR-079は3段構えの対処を書き残しました。ただしここから先は、実装済みの部分と、設計にとどまっている部分をはっきり分けて書く必要があります。両者を混同すると、まだ直っていないものを直ったことにしてしまいます。
まず実装済みなのは、epoch fencing(世代フェンシング)による検出です。LiteralDetectorにepoch_send_ms(VK送信直前のタイムスタンプ)というフィールドを追加し、confirm根拠がgji_last_write_ms() >= epoch_send_msを満たさない限り、その証拠を「自分の世代のものだ」と採用しないようにしました。新しい計装は増やしていません。すでにあったgji_last_write_ms()を、判定の材料として転用しただけです。DetectionResultという型には、成功でも失敗でもない第三のバリアントStaleConfirm(証拠が古い世代のものだった、という状態)を追加しました。成功か失敗かの二値では表せない「これは自分の証拠ではない」という状態を、型として持たせる考え方は、本書がここまで繰り返し使ってきたものです。
先ほどの377ミリ秒の出来事を時系列の箱に並べ直すと、SHOWイベントがどちらの試行を指していたかが一目で追えます。

4番目の箱(SHOW #892)から矢印が遡っている先は、直前のbackspace(cold=264)ではなく、その前のcold=263の送信です。epoch fencingは、この「何ミリ秒遡れば辻褄が合うか」という手計算を、gji_last_write_ms() >= epoch_send_msという不等式に置き換え、送信のたびに機械的に判定させる仕組みでした。
epoch fencingは、300ミリ秒というdeadlineの値そのものには手を触れていません。同じ定数を段階的に釣り上げても別の(より遅い)ケースで同じ誤判定が再発するだけだ、という第3章でtuning-constants.mdという規約になった教訓を踏まえ、その規約は今回、最初からAIへの指示に含まれていました。
ここから先は、まだ実装されていない設計です。ADR-079の「決定」節は、backspaceの回数を精緻化する方向ではなく、破壊できる範囲が構造的に限定された別の操作――ESCキー――に置き換えるという方向を選びました。ESCの破壊スコープは「現在IME ONになっているcomposition」の外には、Windows APIの仕様上届きません。これは第7章の検討時にすでに確認済みの保証です。IME OFF中に確定した文字は、このスコープの外側にあるため、ESCでは一切触れません。「何文字消すか」を数える必要そのものをなくす、という方向転換でした。
ESCという選択には、もう一つ確認すべき前例がありました。VK_ESCAPEをこの用途に使う発想は、実は以前にも一度検討され、却下されていたのです。BUG-29では、候補ウィンドウの表示状態を確かめる目的でESCを送ってHIDEさせる案が検討されましたが、ESCがcompositionをキャンセルして入力テキストごと消してしまうため、VK0で確定した文字まで消す危険があるとして却下されました。しかしBUG-29が恐れていたのは同一compositionセッション内で確定前の文字ごと消えることであり、今回のESCが触れるのはIME OFF中に別のセッションで確定していた文字ではなく、現在進行中のcompositionだけです。過去に却下された案だからと機械的に避けるのではなく、却下の理由がいまの状況にも当てはまるかを、あらためて確かめ直す作業が必要でした。
ただし、単純にESCするだけでは足りません。stale判定が確定するまでの間に、後続の入力がすでに処理されていることがあるからです。今回のトレースでも、backspace実行からstale判定確定までのわずかな間に、スペースと「ん」の合成がすでに動き出していました。ESCは「その瞬間のpending composition」を丸ごと消すため、何もせずにESCすると、正しく進行中だった後続の合成まで巻き込んでしまいます。そこでADR-079は、backspace以降の後続アクションを世代付きでバッファし、ESC後にまとめてreplayする、という限定replayの設計を書き残しています。検討の過程では、「常に一定件数を無条件にretypeするring buffer」という単純な案も検討されましたが、高速タイピング下でstale判定までに複数の後続入力が積み重なるケースを救えないため採用されませんでした。かわりに、composition確定のトリガーとなるキー(is_composition_confirm_key)だけをreplay対象から除外し、そこで打ち切ります。一般解を狙わず、割り切りをそのまま受け入れる判断でした。
このStage 2は、本書執筆時点でまだ実装されていません。実装を進める過程では、意図していなかった副産物のバグも2件見つかりました。1つは、composition確定操作(CompositionReset/NativeF2Consumed)がGJIの生存証拠(gji_idle_ms)を確認せず、実際にはまだ温まっているセッションまで無根拠にcold扱いしていたバグです。もう1つは、StaleConfirmと判定した場合でも、literalである証拠がないまま、無条件にbackspaceを送ってしまっていたバグです。どちらも修正済みですが、1つの根本原因をepoch fencingで塞いだつもりが、隣接する2つの未発見の欠陥が芋づる式に見つかった、というのが実装の実態でした。
だからこそ、ここでは「もう直った」とは書きません。まず検出だけを実機に出し、StaleConfirmがどれくらいの頻度で発火するかを観測してから、ESC・retype・限定replayという回収の設計に進む。この段階的なリスク管理そのものが、この章で採用した工夫です。
設計そのものが書き終わった時点で、「これで治った」と結論づけたくなる誘惑は、常にあります。本章があえて足踏みを選んだのは、その誘惑に対する意図的な抵抗であり、観測を先に立て実装は後から追いつかせるという順序を、設計そのものにも適用したことになります。一つの試行の中の時間軸は、これで扱えるようになりました。次に残るのは、複数のアプリという空間軸です。
設計原則
原則1: 非同期に遅れて届く確認は、証拠の新しさだけでなく、誰の試行のものかを明示的に紐付けなければなりません。
適用条件: 確認・完了通知が非同期に届き、かつ複数回の試行が短時間に重なりうる場面に当てはまります。「最新の観測か」を確かめるだけでは足りず、「どの世代の送信に対する応答か」という帰属先まで区別する必要があります。単発の試行しか起こりえない場面では、この原則を適用する必要はありません。
実装の形: 送信側が世代番号やタイムスタンプを持ち、証拠を受け取った側がそれを照合してから採用します。新しい計装を追加する前に、すでに記録している値を転用できないか、まず確認する価値があります。warm/cold判定のために元々あった値を、そのまま流用できた例もあります。
限界: この原則は、証拠がどの世代に属するかを判定できるだけであり、判定を誤ったときの被害までは抑えません。誤判定への備えは、別の原則が要ります。
原則2: 取り消し操作は、対象を正しく狙えるかではなく、外れたときにどこまで被害が及ぶか(blast radius、破壊範囲)で選ぶべきです。
適用条件: 取り消し操作の対象を確実に狙える保証がない場面に当てはまります。ほぼ常に正しく当てられることに頼るのではなく、外れても致命的にならないことを、設計そのもので保証したい場合に使います。
実装の形: 対象を狙い撃つ操作ではなく、破壊できる範囲があらかじめ構造的に限定された操作を選びます。compositionスコープの外に届かないという仕様上の保証を持つ操作は、その一例です。狙いの精度を上げる努力よりも、外れたときの被害範囲を狭める設計のほうが、長期的には確実です。この考え方は、DBのトランザクション境界やAPIのべき等性設計にも転用できます。
限界: この原則は、範囲が構造的に限定された代替操作が存在することが前提です。そのような代替が見つからない場合は、精度を上げる方向の対策に頼らざるを得ません。
第12章:5日間で、6回ひっくり返した

ここから第VI部です。まとめれば強くなる、とは限らない、という問いをこの部で扱います。第8章は、散らばった状態を一つのFSM(状態機械)にまとめ、その中身をさらに手続きと状態へ整理する話でした。第9章は、時間をまたいで届く観測をどう扱うかという話でした。どちらも、扱う範囲は一つのプロファイル、一つの大きな構造の内側に閉じていました。本章が扱う範囲はもう一段広がります。複数の異なるアプリと複数の異なるIME(日本語入力エンジン)にまたがるロジックを、同じものとして扱ってよいのか。空間をまたぐ同一視を、どこまで信じてよいのかという問いです。
第10章・第11章とも独立した柱です。あちらは、一つの試行が時間をまたいで別の試行と取り違えられる問題でした。本章は、複数の実行文脈(アプリ×IME)にまたがるロジックを、一つのものとして共有してよいかという、質的に異なる軸を扱います。答えは、思っていたよりも単純ではありませんでした。
正しいキーは、5日間で6回入れ替わった
docs/experiments.mdのエントリ01は、2026年6月27日から7月2日にかけて、あるキー選択が何度もひっくり返った様子を記録しています。
| 日付・コミット | 変更 | 判明した事実 |
|---|
06-27 534051a | VK_DBE_ALPHANUMERICへ切替 | 即時OFFを達成、いったん採用 |
06-28 098c663 | F22へrevert | 直前の変更がフォーカス変更時に暴発していた |
06-28 9c3f11e→668a131 | VK_IME_OFFへ切替→即revert | 同日のうちに2回反転 |
06-28 adb856c | VK_KANJIフォールバックへ | F22はTsfNativeで「半角英数」止まりと判明 |
06-28 b271aee | VK_IME_ON/OFFへ全廃 | config1.dbバインド不要・冪等と判断 |
07-01〜02 be3b056〜489cdf1 | MS-IME側も同じキーへ収束 | GjiDirect除外の撤廃で固着バグを解消 |
TSF(Text Services Framework)ネイティブなアプリ、たとえばWindows Terminalで、GJI(Google Japanese Input、Google 日本語入力)を直接入力(DirectInput)へ切り替えるには、どの仮想キーを送ればよいのか。候補はVK_KANJI(トグル式)・VK_DBE_ALPHANUMERIC(半角英数、ただしIMEはONのまま)・VK_IME_OFF(直接入力、冪等)・config1.db経由のF22キーの四つでしたが、単純な一つの答えはありませんでした。
表の最後の行は、GJI以外のIME(MS-IME)でも同じ収束が必要だったことを示しています。プロファイルという軸だけでなく、動作対象のIMEという軸もこの反転劇には絡んでおり、この二つの軸が絡み合っていたことこそが、本章が最終的にたどり着く発見の伏線です。
章のタイトルにある「6回」は、表が示す6月27日から7月2日までの5日間に起きた反転の数です。約5週間さかのぼった5月22日には前史(d4d9e27)もありましたが、VK_IME_ON/OFF双方向制御をChromeが受け付けずVK_KANJIとshadowチェックの組み合わせへ撤退した、この一件は「6回」の数えには含まれていません。
一つひとつの反転には、その場では正しい理由がありました。VK_DBE_ALPHANUMERICは即時OFFを達成しましたが、フォーカス変更時に無関係な副作用を起こしました。F22は副作用こそ起こしませんでしたが、TSFネイティブなアプリでは「半角英数」までしか進まず、真のIME OFFに届いていませんでした。「即座に効く」ことは「安全である」ことを意味せず、F22の遅延に見えた欠点は、実はフォーカス変更時の暴発を偶然抑えていました。この、一つの環境で正しかった答えが別の環境では通用しないという構造は、次に紹介する定数のインフレと共通の根を持っています。
定数は増え続け、無関係な機能が同じ変数を取り合った
IME OFFキーの反転と並んで、もう一つの症状がありました。Chrome向けのprobe最小待機時間です。CHROME_PROBE_MIN_MS(20ms)から始まったこの定数は、CHROME_PROBE_LONG_IDLE_MIN_MS(100ms→200ms)を経て、CHROME_PROBE_F2_GJI_IDLE_MIN_MS(350ms)へと、5週間のうちに4段階で釣り上がりました。それぞれの段には「実測最大180ms程度にマージン170msを足して350ms」というように、一応の実測根拠が添えられていました。場当たり的に決めた数字ではありません。それでも、条件フラグの組み合わせが増えるたびに、同じ目的の定数がprobe_fsm.rsという一つの共有分岐へ次々に追加され続けたという構造は変わりませんでした。本来はプロファイルごとに固有であるはずの待機ロジックが、プロファイルに依存しないはずの共有関数のパラメータとして表現されていたのです。
もう一つの実例は、共有可変状態の相互汚染です(BUG-23)。VcXsrv由来のstuck Ctrlキー対策として、PHYSICAL_KEY_STATEという共有テーブルにinjectedフィルタを追加した変更がありました。この変更が、まったく無関係なpanic_reset()の自己注入(send_all_modifier_key_ups())まで巻き込んで弾いてしまい、パニックからの回復機能そのものを止めていました。パニックからの回復は、他のあらゆる保護機構が失敗したときの最後の砦です。その最後の砦が、無関係な変更の巻き添えで沈黙していたことは、単なる一バグ以上の重みを持っていました。一つの共有テーブルを、物理キー追跡・stuck Ctrl対策・panic回復という三つの目的が、互いに調整のないまま読み書きしていたことが原因でした。
キー選択の反転も、定数のインフレも、共有状態の汚染も、それぞれ別のバグとして個別に報告され、個別に直されてきました。しかし三つを並べて眺めると、偶然の一致には見えなくなります。これらはcrates/awase-windowsのIME制御が採ってきた「単一の汎用ループにプロファイルごとの分岐を持たせる」という設計判断が生んだ、コストの実例カタログではないか。この疑いが、次の仮説につながります。
「共通化」を前提にしてきたこと自体を疑った
awaseのIME制御は、AppImeProfile(Standard/Imm32Unavailable/TsfNative)という区分に対して、drift correction・cold-start probe・literal detect・focus settleという一つの汎用ループを適用し、ループの内部でプロファイルごとに分岐する構造を一貫して採ってきました。これは事故ではなく、意図的な設計判断でした。AppImePolicyのコード冒頭には「アプリ差分はこの型に閉じ込め、reducer本体にif-elseを増やさない」という原則が明記されています。
ただし、この一歩は途中で止まっていました。AppImePolicyは、プロファイルごとに異なるデータ(focus_settle_ms・default_feedback・actuator_kind)を一つの構造体にまとめる、という段階までは進んでいました。けれども、そのデータを読み出すロジックの側は、依然として一つの共有関数に留まったままでした。データは分かれているのに、コードは共有されている。統一と分離のどちらの利点も、中途半端にしか得られていない状態です。
この状態を言葉にしたのは、Claude Fable 5との壁打ちでした。「フレームワークは既にデータの中でフォークしている。プロファイル別の定数、アプリ別のキー選択。コードだけ共有してデータが分岐している現状は、統一と分離の悪いとこ取りだ」という指摘です。この一言が、症状の背後にある仮説をはっきりした言葉に変えました。「共有ループ+プロファイル分岐」という設計判断そのものが、ここまで見てきた症状の波及元ではないか。
ただし、この仮説にすぐ飛びつくわけにはいきません。「重複させれば安全になる」という対抗仮説も、同じくらいもっともらしく聞こえます。共有ループを分離すれば、プロファイルごとの副作用は起こらなくなるはずだという見立ては、直感としては自然です。しかし直感だけで大規模な書き換えに踏み切れば、もし見立てが外れていたときの後戻りは大きくなります。共有か重複か、どちらが正しいかを直感や標語で決めるのではなく、実際のデータで確かめる。これが次に採った実験でした。
撤回の理由を書き残さなければ、同じ発見を何度もやり直す
反転が繰り返された最大の理由は、前回それを捨てた理由がコミット本文から辿れなかったことです。実際VK_DBE_ALPHANUMERICは複数回採用と撤回を繰り返し、そのたびに「これは半角英数であって直接入力ではない」という同じ事実を再発見していました。
この経緯から、IME制御に関わるrevertコミットの本文にはアプリ・IMEの状態・再現手順を必ず書くという実験ログ規約が生まれました(第14章)。決定の日付と対話の出所まで書き残す規律も同じ延長にあり、標語ではなく記録で確かめるという、この章を貫く態度そのものでした。
43件を、直感ではなく数えることにした
実装に着手する前に、まずdocs/known-bugs.mdに記録された既知バグ43件を全件読み、cross-profile spillover・single-profile・cross-cutting infraの3つに分類する。これがADR-081の第一歩(Phase 0)でした。「DRY」や「早すぎる抽象化を避けよ」といった標語で議論を打ち切るのではなく、「共有が実際に何パーセントの実害を生んでいるか」を実測で問う姿勢そのものが、この章でもっとも実践しやすい工夫です。この分類は「直感的にどちらの側に近いか」ではなく、docs/known-bugs.mdの各エントリの記述を読み、機械的に43件すべてへ当てはめて決められました。
| 分類 | 件数 | 比率 | 意味 |
|---|
| (a) cross-profile spillover | 11件 | 26% | あるプロファイル向け修正が別プロファイルに波及 |
| (b) single-profile | 4件 | 9% | 単一プロファイルに完全に閉じる |
| (c) cross-cutting infra | 28件 | 65% | プロファイル分岐と無関係な汎用インフラの欠陥 |
26%は「共有ループの分岐面」が実害を生んできた証拠であり、分離を進める根拠になります。この11件には、第10章で扱った柱Aの主題、BUG-43(「同じir_apply_drift_correctionが観測ストアを更新しないため無限に再送する」開ループ/閉ループの取り違え)も含まれていました。時間軸の問題として見たBUG-43は、空間軸で見れば「共有関数のnon-ImmCross分岐が3世代連続で再発した事例」でもあります。同じ一件のバグが、どの軸から光を当てるかで違う教訓を語ります。
一方、残りの65%はプロファイル分岐と無関係な、スレッド配送・タイマー・検出ヒューリスティックといった汎用インフラそのものの欠陥でした。ここはドライバをいくら分離しても、同じ形のバグが同じ場所に残るだけです。
「重複すれば安全」という仮説にも、反証があった
もっと重要な発見は、(c)の28件のうち6件でした。この6件は、既に部分的に分離されている実装同士が、互いの同期を怠って乖離したことが原因のリグレッションでした。TSF用とChrome用に分けて実装していた検出ロジックが、それぞれ別々に手が入るうちに食い違っていく。この6件のうち一件(BUG-30)は、分岐していた検出ロジックを一本化したことそのものが直接の修正になった、逆方向の実例でした。分離が原因でバグが生まれ、統一がそのバグを解消する。まさに「重複させれば安全」という側の対抗仮説を、データそのものが裏切っていました。
「共有すれば危険、重複すれば安全」という単純な対立軸そのものが、この6件によって崩れます。共有にも重複にも、それぞれ別の失敗モードでバグを生む余地がある。データが示していたのは、二項対立ではなく三すくみに近い構造でした。
分離の判断を実装より先に済ませるため、費用も机上で見積もりました。ImeOpenStrategyの4戦略は共有部分がtrait定義12行+走査ループ約25行のみとほぼ分離済みで、AppImePolicy(構造体17行+match35行+テスト約40行、計約92行)を独立したtrait実装へ完全分離した場合の純増は約60〜100行と見積もられました。行数という粗い尺度でも、重複のコストを感覚でなく数値で見積もるという手法自体が、この章の工夫の一つです。
結論は「全面Go」でも「全面No-Go」でもなく、限定的なGoでした。26%という比率は無視できずImeOpenStrategyは分離済みで移行コストが小さい一方、65%は分離では解決せず、6件の反証データは「重複すれば安全」という仮説も否定している。両方を踏まえ、最小の1プロファイルだけを試験実装してから見積もりを更新する、という段階的な進め方が選ばれました。
この限定的なGoには、もう一つの備えがありました。ADR-081は実装に着手する前に完了の判定基準も先に決めており、cross-profile spilloverの11件と分離済み実装がdriftした6件、合わせて約40%の再発率が下がったかどうかで判定します。総バグ件数だけを見れば分離と無関係な65%の変動に埋もれ、施策の効果を実装前から見誤ることを防いでいます。
答えは「共有か重複か」ではなかった
Phase 0で集めたデータを見つめ直すと、決定的な気づきがありました。「共有か重複か」という問いの立て方自体が、単純化しすぎていたのです。43件のデータは、どちらか一方を選べば解決するという二項対立ではなく、どちらの設計でも、別の失敗モードでバグが生まれることを裏付けていました。
問いを立て直すと、根本原因が見えてきます。awaseのIME制御は、二つの独立した軸を、一つの次元へ押し込んでいたのです。
一つは、プロファイル軸(静的)です。アプリがIMEの状態をどこまで観測・制御できるか(Standard/Imm32Unavailable/TsfNative)は、そのアプリが起動している間、変化しません。もう一つは、IME軸(動的)です。実行中にどのIME(GJIかMS-IMEか)がアクティブかは、ユーザーが切り替えれば実行時に変わります。この二つを同じmatch文の中で一緒に分岐させていたことが、症状の根にありました。
もし各プロファイル用のドライバへ単純に分離していたら、GJI固有の処理を三つのドライバがそれぞれ再実装することになっていたはずです。これはまさに、Phase 0で見つけた反証データ6件と同じ失敗モードの再生産でした。静的な軸と動的な軸を取り違えたまま分離を進めると、分離そのものが新しいバグの温床になります。
設計の候補は二つ検討されました。一つは動的な観測値をメソッド引数として各ドライバへ渡し、内部で分岐させる案。もう一つは、GJI直接制御を共有機構として一箇所に残し、各ドライバはuses_gji_direct() -> boolという静的な宣言だけで表す案です。採用されたのは後者でした。「profile軸(静的)とIME軸(動的)は本質的に直交する2次元だ。前者は各ドライバのメソッド内分岐として1次元に押し込む案であり、結果として3ドライバがそれぞれGJI分岐を再実装することになる。これはPhase 0で見つかった反証データ6件と同じ失敗モードの再生産である」と記録されています。
この章の冒頭に立てた問い、「共有か重複かは、何で決めるべきか」への答えは、「重複させるかどうか」という選択そのものではありませんでした。その変化がどの軸に属するかを、先に見極めることです。静的な軸は型で分離し重複を許容してよく、動的な軸は実行時に変わるため一箇所の共有機構に残す。IME OFFキーが5日間で6回ひっくり返っていたのは、この見極めをしないまま、静的な判断と動的な判断を同じ場所に書き込み続けていたからでした。
静的な軸は型に、動的な軸は一箇所に閉じ込めた
採用したのは、ImeProfileDriverという trait(複数の型に同じ振る舞いを約束させる、Rustの仕組み)を新設し、プロファイルごとに独立した構造体(ImmCrossDriver・Imm32UnavailableDriver・TsfNativeDriver)を持たせる設計です。各ドライバは、自分のcold-start probe予算・drift correctionのfeedback方針・IME OFFキー選択・focus settle時間を、自分自身で所有します。プロファイル軸という静的な変化は、型として分離し、重複を許容します。
型を分けるだけでは、実際の開発は元に戻ってしまいがちです。複数のドライバにまたがる変更を書くたびに、共有ロジックへ逆戻りする誘惑が生まれるからです。この誘惑に歯止めをかけるため、ある変更が複数のドライバをまたぐ場合には、なぜまたぐ必要があるのかをコミットメッセージで説明する規約も、あわせて敷かれました。フォールバックの連鎖でどうにか帳尻を合わせるのではなく、各ドライバが自分の責務を自己完結させることを優先する、という考え方です。
trait ImeProfileDriver {
fn uses_gji_direct(&self) -> bool;
fn owns_physical_kanji(&self) -> bool;
fn ime_off_key(&self) -> ImeOffKey;
}
一方、IME軸という動的な変化はGjiDirectMechanismという共有機構として一箇所にだけ実装しました。この機構への到達可能性は、非公開フィールドを持つcapability token(GjiDirectAccess)によって型として閉じられ、唯一の公開コンストラクタはuses_gji_direct()がtrueを返すドライバにしか値を渡しません。ImmCrossDriverのようなこの機構を必要としないドライバは、構造的にGJI機構を呼び出すコードを書くことすらできず、実行時チェックではなくそもそも書けなくする強制の仕方です。
二つの軸がそれぞれどこに置かれたかは、図にすると分かれ目がはっきりします。

上段の3つのドライバは、それぞれが自分のcold-start予算・feedback方針・IME OFFキー選択を所有する、型で分離された静的な軸です。下段の共有機構は実行中に切り替わる動的な軸で、uses_gji_direct()がtrueを返すドライバからしか矢印が伸びていません。
移行は一度に切り替えず、段階を分けました。ADR-082のjournal配線を先行させるPhase 0.5から始まり、Imm32UnavailableDriverを切り出す1a、TsfNativeDriverを切り出す1b、5件の契約テストとレジストリを備える1cと進み、実機への配線1dと旧経路の撤去1eはまだこの先の計画です。全体を貫く方針は、分離を一括完了させてから旧経路をまとめて消すのではなく、1プロファイルのソークに合格するたびに、即座にその旧経路だけを撤去するという漸進性でした。
Phase 1cで実装された契約テストは5件です。IME-ON経路を持つドライバは、消えてしまった状態(ObservedEisu)を救済する経路を必ず対で持つこと。物理KANJIキーを自分の管轄だと宣言したドライバは、それを漏らさず処理すること。Blindのgive-up後には観測ストアへ何も書き込まないこと(第10章で見た観測の偽装、BUG-33型の再発防止)。beliefを高速にONへ倒す経路でも、必ずGjiFsmの同期を通ること(反証データ6件のうちもっとも実害が大きかった失敗モードへの対応)。そしてGJI機構の状態遷移は、どのドライバ経由でも同じGjiFsm同期を通ること。五つとも、Phase 0が実データから拾い出した失敗モードに、一対一で対応しています。この時点(Phase 1c)ではcargo test -p awase-windows --libが172件すべて通過しており、Windows実機やwine環境がなくても型設計と契約テストの正しさはLinux上で検証できることを、ドライバとcapability tokenを純粋なデータ構造として設計した効果として示しています。
分離の前後を整理すると、変化がどこに起きたかがはっきりします。
| 変更前 | 変更後 |
|---|
AppImePolicy(構造体+match、約92行) | ImeProfileDriver実装3種+共有機構1種 |
| GJI直接制御(各所に暗黙のif分岐) | GjiDirectMechanism+capability token1箇所 |
| プロファイル差分(データのみ分離) | プロファイル差分(データとロジックの両方を分離) |
| 契約テストなし | 契約テスト5件(Phase 1c) |
もっとも重要なのは3行目です。以前のAppImePolicyはデータだけを分けてロジックは共有したままの「Step 1.5」で止まっており、今回の分離はその中途半端さを解消する変更でした。
実機への配線(Phase 1d)と旧経路の撤去(Phase 1e)は、本書執筆時点でまだ着手されていません。移行の安全策として計画されているのはstrangler figパターンで、新旧の経路を並走させる期間中、実際に外界へ書き込む(actuateする)のは常に片方だけとし、もう片方は結果を比較するだけの読み取り専用shadowにとどめ、二つの実装が食い違ったときにどちらが真実か分からなくなる事故を構造的に排除します。ここまでの型設計と契約テストはWindows実機のいない環境でも検証できましたが、実際にキーが正しく届くかは、まだ確かめられていません。
軸を見誤れば、同じ失敗が形を変えて戻ってくる
この章の答えは、静的な軸を型で分離し、動的な軸を一箇所の共有機構に残す、という一つの判断基準でした。この基準が使えるのは、変化の軸が二つに分解でき、かつ直交している場合に限られ、見誤ればPhase 0が暴いた反証データと同じ失敗が形を変えて再発します。43件という数字もこの一つの手続き群(IME制御)における内訳にすぎず、Phase 1d・1eという移行のクライマックスも、まだ実機で確かめられていません。
型で分離するという答えそのものにも、後日談があります。2026年8月、「Rustの型システムをもっと生かせないか」という提案を受け、ImeProfileDriverとは別に、capabilityをtrait静的分岐で表す案と、観測プールをsealed trait(外部から新しい実装を追加できないように閉じたtrait)で2分割する案が検討されました(ADR-089)。しかしどちらも却下されています。理由は、traitが表現できるのはプロファイル軸のような静的な変化だけであり、実際には(プロファイル/IME種別)という2軸を同時に扱う必要があるため、traitという表現手段そのものが足りなかったからです。const表(caps(p, k)という関数)のほうが、traitより精緻にこの2軸を表現できると判断されました。この帰結として、この章が「まだ実機で確かめられていません」と書いたImeProfileDriverのPhase 1d(実配線)・1e(旧経路撤去)は、その後実機の有無とは無関係な理由で、正式に凍結されています(ADR-090)。型状態パターン自体が撤回されたわけではなく、実際に失敗を踏んだ3箇所――観測プールの排他・Actuationの型状態チェーン・GjiFsm同期義務――にだけ絞って投下し直されました。導入後の記録は、「型化の成果は本数が減ることではなく、同じ本数のガードがより広い範囲を守る形に置き換わることだった」と正直に振り返っています。軸を型で分離することと、その型をどう表現するか(traitか、それとも一段抽象度の低いconst表か)は、別の設計判断でした。
ここから得られる原則は、この章の「静的な軸は型に、動的な軸は一箇所に」とは別の一段です。型による抽象化は、それが表現できる軸の数までしか安全に効きません。実際に変化する軸の数が、選んだ型の表現力を超えていないかを、型を選ぶ前に数えておく必要があります。数え間違えれば、型を捨ててやり直すコストは、最初から数を数えるコストよりずっと高くつきます。
型で分離するという答えには、もう一段続きがあります。2026年8月、第11章で見た世代フェンシング(epoch fencing)の識別子――epochとウィンドウハンドル(hwnd、生成したウィンドウを指す番号)の束――の役割整理(ADR-106)で、観測を受理する仕組みと、actuationの着弾先が同一かどうかを確かめる仕組みを、同じ機構に統合してよいかが争点になりました。Opus(Claude Opusモデル)2体による敵対的レビュー(互いの設計を批判させ合うレビュー)は2ラウンドを経ても収束せず、著者は実機実験ではなくdocs/known-bugs.mdと既存ADRを掘り返す作業で決着させています。
過去の記録を数えると、別目的の値を1つの機構に共有したケースは、実際に2回バグを起こしていました。idle-conv-checkのスパムガード用フラグをresyncトリガーと共有し、無関係なキーが先にin-flight(処理が完了せず宙に浮いている状態)を掴んでresyncが早期終了した例と、effective_open()がengine内部の判断とOSへの書き込み許可という2つの役目を兼ね、1つのbool値(true/falseだけを持つ値)の反転が両方を同時に動かしてしまった前掲(第9章)の例です。一方、同じ形の型を複数箇所で別々のインスタンスとして使い回すこと自体には、失敗の記録がありませんでした。ここから「値を共有するな、型のパターンだけ共有せよ」という基準に到達しています。
失敗した2件と、成功した1件を並べると、分かれ目は「共有したもの」の違いにあります。

BUG-77とeffective_open()は、どちらも「1つの値」を2つの異なる目的に使い回したことが原因でした。一方ActuationTargetは、同じ形の型を、着弾先ごとに独立したインスタンスとして複製しています。共有したのは値ではなく、型という設計の型紙(パターン)だけでした。
ここから得られるのは、この章の「静的な軸は型に、動的な軸は一箇所に」よりもう一段先の判断です。分離した軸を表す型を複数の使用箇所へ配るとき、「同じ形に見えるから1つにまとめたい」誘惑と、「別の目的のために作られた値だから分離しておくべきだ」という慎重さのどちらを取るかは、実機で挙動を試しても決まりません。過去に同じ統合を試みて実際に壊れた記録があるかどうかを、先に数えるほうが近道でした。なお、この統合の可否そのもの(決定5)は、着手条件が整わずまだ実装されていません。
他分野への転用
「静的な軸は型で分離し、動的な軸は一箇所に閉じ込める」という発想には、他の分野にも同じ形の実例があります。Webサービスのアクセス制御でよく使われるロールベースアクセス制御(RBAC)は、ユーザーの役割(管理者か一般ユーザーか)という起動時に決まって滅多に変わらない静的な属性と、そのユーザーがいまログイン中かどうかというセッション状態という動的な属性を、意図的に別の仕組みとして扱います。役割は権限テーブルという型に近い構造で管理し、セッションは有効期限つきの一箇所の共有ストアで管理する。両者を一つのテーブルへ一緒くたに書き込むと、本章の反証データ6件と同じ形の同期漏れが起こります。サブスクリプション型のSaaSでプラン種別とセッション状態を分けて管理する設計も、同じ形の分離です。
マイクロサービスの移行パターンとして知られるstrangler figは、新旧の実装を並走させながら、実際に書き込む権限だけは常に一方に絞ります。本章の移行計画は、この分野で繰り返し発見されてきた安全策の一つの適用例です。43件のバグを定量分類してから決めるという手法も同様で、「感覚ではなく実測で決める」という態度は、ソフトウェア開発に限らず再現しやすい工夫です。
設計原則
原則:「共有か重複か」は、直感ではなく既存のバグをコストとして数えてから決めます。
適用条件: 複数の実行文脈にまたがるロジックを、共有すべきか分離すべきか判断に迷ったときに働きます。「DRY」や「早すぎる抽象化を避けよ」といった標語だけでは、どちらの立場からも都合よく引用できてしまい、議論が収束しません。
実装の形: まず既存のバグ台帳を全件読み、症状がどの軸に起因するかを分類します。次に、変化の軸が複数あるなら、それぞれが静的(実行中に変わらない)か動的(実行時に変わる)かを見極めます。静的な軸は型やtraitの実装として分離してよく、動的な軸は一箇所の共有機構にまとめ、capability tokenのような型でアクセスを制限します。分離にかかる費用は、実装に着手する前に、型シグネチャの行数程度の粗さで見積もっておくと、途中で後戻りする可能性を減らせます。
限界: この原則はバグ台帳が十分に蓄積されている場合にしか使えません。新規に書き始めるコードには適用できず、まず動くものを作ってから、実際に踏んだ失敗を数えるという順序が必要です。また、変化の軸が二つより多く、互いに直交していない場合には、この基準だけでは割り切れません。
第13章:もう要らないのでは、と誰が言うのか
ここまでの章は、開/閉ループ(柱A)と共有/重複(柱B)という、二つの技術的な発見を追ってきました。ここから第VII部では、同じ工夫を開発そのものに向けます。本章が扱うのは、どちらの発見とも独立した第三の軸——技術的に「何を直したか」ではなく、その直し方の発想がどこから来たのかという、開発プロセスそのものの観察です。
同じ時期のrust-nicolaには、方向の異なる二つの大きな変更が同時に進んでいました。ひとつは、長らく積み上げてきた予防策を丸ごと削る変更。もうひとつは、長らく共有してきた仕組みをあえて分ける変更です。どちらも「積み上げてきたものの前提」を疑わなければ生まれようがない種類の変更で、この二つを並べると、大局的な飛躍がどこから始まるのかという問いに、ひとつの答えが見えてきます。
偽陽性が消えても、二重の防御は消えなかった
known-bugs.mdには、2026年7月10日付でこう記録されています。
なぜ偽陰性が実害として顕在化していないと考えられるか(2026-07-10、ユーザー仮説): 現状はcold-start予防(warmup)が広く・保守的にかかっているためneeds_literalがほぼ常にtrueになり、LiteralDetect自体がスキップされるケースが実運用でほとんど発生していない可能性が高い。予防のタイミング・適用範囲を絞り込んだ場合に、この偽陰性が顕在化する可能性がある。実機での検証でしか確認できない。
偽陰性とは、実際には変換前のローマ字が残ったままなのに、システムがそれを見逃してしまう状態のことです。予防が広くかかっているせいで、見逃しを検知するLiteralDetect自体が起動する機会をほとんど失っている、というのがこの仮説の核心でした。この記録が問うているのは、新しいバグへの対処ではなく、今動いている予防機構はもう要らないのではないか、という積み上げてきたものを疑う方向の問いでした。
この仮説の背景には、GJI(Google Japanese Input、Google 日本語入力)のcold-start対策として、awaseがある時期持っていた二重の予防的な防御層があります。ひとつは、long_idleと判定されたときの待機時間を細かく調整する待機行列です。ここで使われていたeager_settle_msは、第3章で600ミリ秒から1500へ、そして500へと調整を繰り返した、あの待機定数です。もうひとつは、VK_Aと確定用のバックスペースをあらかじめ送って様子を見る捨て打ち機能です。
この二層は、最初から設計として一度に用意されたものではありません。GJIが起動直後にどこまで応答できるかは事前に確かめようがなく、個別のバグに対処するたびに「念のため待つ」「念のため様子見のキーを送る」という予防策がひとつずつ足されていったものでした。
その後、合成結果を1文字ずつ確認するreactiveな機構(per-VK confirm)が導入され、BUG-24の偽陽性側には実効的な対処が入りました(BUG-24自体は今も「未修正」のまま記録)。予防に頼らず、実際に起きたことをその都度確認する方針への転換であり、理屈のうえでは待機行列・捨て打ち機能の役割の大半と重複します。それでも二層は「二重の保険」として、動いているコードに触れて壊すより残すほうが安全という惰性で残り続けました。偽陽性側が直ったこと自体はめずらしくなく、めずらしかったのは、直ったあとに残った「この二層は、もう要らないのではないか」という問いのほうでした。
三層がどう重なり、削除後にどう変わったかを図にすると次のようになります。

待機行列と捨て打ち機能が担っていた役割の大半は、per-VK confirmという一層に、すでに移っていました。
この仮説を検証するには、通常のバグ修正とは逆方向のリスクを取る決断が必要でした。バグを直す作業はリスクを減らす行為ですが、安全策を外して確かめる作業は一時的にリスクを増やす行為です。何も起きなければ削除の根拠になりますが、外して初めて文字化けが起きれば実際のユーザーが直接被る実害です。検証は、本番の安全策をあえて無効化する診断フラグDIAG_DISABLE_PROACTIVE_TSF_WARMUPを有効にすることで進められました。
外して、確かめた
2026年7月11日、DIAG_DISABLE_PROACTIVE_TSF_WARMUPによって、Windows Terminal・TSF側の3つの防御層——Phase 2のSendFreshF2(様子見のキー送信)、Phase 5aのStartSacrificialWarmup(捨て打ち機能本体)、effective_prepend_f2によるバッチ同梱(送信直前に念のためキーを差し込む処理)——が同時に無効化され、実機のWindows Terminalに実際に日本語を入力して挙動が観測されました。
結果は次の通りでした。エンジン再有効化直後を示すreason=SetOpenTrue系(cold=1,10,11,12,14)では、予防がゼロでもreactiveな検知だけで実害を防げたことが実機で確認できました。既存の"kお"バグのパターンでis_partial_literal()が正しく破損を検知し、ESCキーによる回収が機能したのです。nc_fired=true系(reason=ReinjectConfirmKey/CtrlKeyBypass、cold=5,6,9,13)でも、偽陰性の証拠は見つかりませんでした。
ただしここには留保がついていました。1セッション・限られた条件下での確認にすぎず、idle時間や他アプリ、複数セッションへ条件を広げたわけではないと明記されていたのです。
検証はここで終わりませんでした。続くexperiment/skip-cold-probe-waitブランチで数日間の実機ソークが行われ、Windows TerminalとChromeの両方でcold=61から74を超える範囲まで条件を広げても、疑わしいリテラル化が実際に発生した例はゼロ件でした。
ここまでの道筋を振り返ると、ひとつの型が見えてきます。診断フラグでいったん無効化する、実機で日をまたいでソークする、問題が出なければ恒久化する、恒久化したコードを物理削除する、そして削除の過程で見つかった副産物――到達不能になったコードや、逆に生きていた依存――を調査する。この4段階は、この一件だけの手順ではなく、「もう要らないのではないか」という仮説を安全に検証したい場面であれば、どこでも再利用できる型です。
この4段階を図にすると、次のようになります。

4番目の削除は、恒久化したコードを消すだけでなく、その過程で見つかった副産物まで洗う工程を含みます。
2026年7月18日、コミットd495649が、待機行列と捨て打ち機能の機構一式を物理削除しました。コミットメッセージには、こう記録されています。
捨て駒キー機構一式(StartSacrificialWarmup/SacrificialResend、SacrificialWarmupCoro/ImeOffOnWarmupFsm、is_long_cold重症度分岐)を物理削除。……連鎖的に不要となったdead code……も削除。
削除の範囲は、捨て打ち機能そのものにとどまりませんでした。WarmupKind::FreshF2・ReWarmup・ProbeWithSettle、ColdReason×long_idleのeager_settle_ms・probe_min_ms、Chrome向けのF2事前送信・probe事前待機のコードも消えました。連鎖的に不要となったdead codeも、あわせて削除されました。ConvModeMgr::effective_charset・needs_conv_restore_write・mark_conv_restore_written、ProbeIo::increment_consecutive_count、DispatchResult::SwitchMachine、TsfEnvSnapshot::gji_candidate_visibleです。
この作業は機械的なものではありませんでした。削除しかけてsend_chrome_gji_reinit_and_pollという関数が別経路から直接呼ばれている生きた依存だと気づき、あわてて復元した箇所もあったのです。「もう使われていないはず」という判断は、一件ずつ呼び出し元を追わなければ確定できません。
この一連の削除を可能にした最初の一歩は、AIが修正ループの中で自発的に出した提案ではありませんでした。known-bugs.mdは、次のように明記しています。
ユーザーの判断で DIAG_DISABLE_PROACTIVE_TSF_WARMUP=true のまま実運用を継続し、より広い条件下で問題が顕在化するか追加検証中
AI(Claude Sonnet)が担ったのは、この一連の段階を緻密に、手戻り少なく実行する丁寧さでした。それでも、この作業がそもそも始まった理由――「もう疑ってよい」という着想の起点――は、人間側にありました。
同じ着想が、Fableとの壁打ちからも生まれた
F1とは対照的な事例が、同じ時期に並走していました。プロファイル別ドライバへの分離(ADR-081)という、per-appkind設計への転換です。
これは真逆の意味での大局的な提案でした。F1が「積み上げてきたものを削る」飛躍だったのに対し、こちらは「共有してきた仕組みを分ける」飛躍です。IME制御は長らく、共通の汎用ループにプロファイル(awaseが相手にするアプリをIME状態の観測・制御可能性で分類したグループ)ごとの分岐を差し込む設計を採ってきました。共有したほうが保守しやすいという、それ自体は妥当な判断です。Phase 0として、まずは限定的な分離をPR #31で試す(Limited Go)という一歩は、既に踏み出されてもいました。この分離の全体像と、そこから生まれたexperiment-logging.mdという記録規約は、前章で見た通りです。
この着想の核心は、「AppImePolicyは共通ロジックとプロファイル別データという、中途半端な段階で止まっている」というフレーミングでした。この見立ては、Claude Sonnetが通常の修正ループの中で提案したものではなく、Claude Fable 5(以下、Fable)との「壁打ち」――対話相手に前提から疑わせる目的で設ける、意図的な別モードの対話――から生まれたものでした。ADR-081冒頭の「ステータス」節には、こう記録されています。
提案中 → Phase 1計画確定(2026-07-25、Claude Fable 5との壁打ちから起票。……Phase 1計画はOpusによる立案+Fableとの壁打ちで未確定点を解消し確定)。
壁打ちで得られた指摘は、具体的でした。
フレームワークは既にデータの中でフォークしている(プロファイル別定数・アプリ別キー選択)。コードだけ共有してデータが分岐している現状は、統一と分離の悪いとこ取り。
同様の経緯は、ADR-080でも見られました。Sonnet自身は「closed-loop用の機構をopen-loop対象に適用しているミスマッチ」というフレーミングで問題を捉えていましたが、Fableに独立して検討を依頼すると「actuationが終端契約(liveness)を持たない一級市民になっていない」という直交する切り口が返ってきました。通常の修正タスクとして問われるか、前提を疑ってよい対話として問われるかで、返ってくる論点の高さが変わっていたのです。
問いを変えたのは誰か
ここでFableとSonnetを比べ、「賢いモデルに変えたから大きな提案ができた」という物語にまとめてしまいたくなりますが、この結論には飛びつけません。
F2では、モデル(SonnetからFableへ)と問いの立て方(「このバグを直せ」から「この前提を疑ってよい」へ)が、同時に変わっています。したがってF2だけからは、賢いモデルに変えたから飛躍が起きたのか、疑うことをタスクとして渡したから飛躍が起きたのかを、完全には分離できません。これは実験計画でいう交絡変数――ある結果の原因が複数考えられ、互いに区別できない状態――の、典型的な形です。
だからこそ、F1をF2より先に置きました。F1(待機行列・捨て打ち機能の全廃)は、モデルを変えずに、Claude Sonnetのまま、人間が問いを変えただけで飛躍が起きた事例です。F1にはモデル交代という交絡変数が存在しません。この一点で、F1はF2より証拠として強いのです。
F1とF2を並べると、何が同じで何が違うかが一目で分かります。
| 観点 | F1(待機行列・捨て打ち機能の全廃) | F2(per-appkind設計への転換) |
|---|
| モデル | Claude Sonnet(変更なし) | Claude SonnetからFableへ |
| 問いの立て方 | 「このバグを直せ」から「もう要らないのでは」へ | 最初から「この前提を疑ってよい」 |
| 着想の起点 | ユーザー(人間) | Fableとの壁打ち |
| 交絡変数 | なし | モデルと問いが同時に変化 |
表の「交絡変数」の行が、この2つの事例の違いをそのまま表しています。F1の列には、交絡変数がありません。F2の列と見比べたとき、この違いこそが、F1をF2より先に置く理由です。
モデルを変えずに問いだけを変えても、飛躍は起きました(F1)。だからこそ、モデルと問いが同時に変わったF2についても、少なくとも一部は「問いの立て方」側の効果だと推測する根拠があります。F1という交絡変数のない証拠があるからこそ、F2という交絡した証拠も、ある程度は同じ方向を指していると読めるのです。これは完全な因果証明ではありません。「賢いモデルの効果はゼロだった」とまでは言い切れませんし、言うつもりもありません。あくまで状況証拠の積み上げにすぎない、という留保を、ここに残しておきます。
この2つの事例を並べると、一般化できる形が見えてきます。「次の不具合を直せ」という問いに答え続けるループは、構造的に保守的な提案しか生みません。F1の待機行列・捨て打ち機能の各層は、その都度局所的に正しく、テストも通り、既存の挙動も壊しませんでした。しかし「積み上がった結果、今もこれ全部が必要か」を問う機会は、次のチケットを解決するというタスクの外側にあり、修正ループの中には構造的に存在しません。
大局的な飛躍の起点は、もう要らないのではないかという、リスクを取る仮説を人間が持つことでした。F1の核心は、新しい安全策を追加する判断ではなく、今動いている安全策をあえて外して確かめるという逆方向の決断でした。この技術的な非対称性は、さきほどの認識論的な非対称性とは別の角度からの裏づけです。
AIが本質的に保守的なのではありません。渡された問いの範囲でしか、大局的になれないだけです。実行と着想は別の能力であり、この章で見た4段階の型をAIは高い精度でこなせますが、着想の一歩まで手放してよいわけではありません。大きな変更を提案させたいなら、修正ループの延長として期待するのではなく、疑うこと自体をタスクとして渡す別の対話――壁打ちや独立レビュー、複数モデルへの同一問題の再検討――を意図的に設ける必要があります。人間が担うべきは、何を疑うかという最初の一歩であり、疑ったあとの安全な進め方まで背負う必要はありません。
他分野への転用
「疑ってよい」という役割を、独立させる発想は他の場所にもある
「次の不具合を直せ」という問いと、「この前提を疑ってよい」という問い――この区別は、ソフトウェア開発だけの話ではありません。
医療の現場には、セカンドオピニオンという制度があります。同じ検査データを別の専門家に「この診断で合っていますか」ではなく「前提自体を疑ってよい」というモードで見せると、最初の医師とは異なる結論にたどり着くことがあります。担当医が悪いのではなく、ひとつの治療方針を進める役割の中では、その方針自体を疑う問いが構造的に生まれにくいのです。
組織の意思決定にも、似た仕組みがあります。レッドチーム演習は、通常の業務ループの外側にあえて「これは本当に必要か」を問う役割を切り出します。日々の業務改善は既存の枠組みを前提にした微調整になりがちで、前提を疑う役割を別枠として用意しなければ、その問い自体が生まれません。
これらに共通するのは、疑う役割を日常のループから独立させるという設計です。誰に、どんな問いを、どんなモードで渡すかを設計する発想は、ソフトウェア開発に限らず意思決定の現場全般に転用できます。
本書はここまで、空間をまたぐ同一視(柱B)と時間をまたぐ同一視(柱A)を、コードの中の設計判断として見てきました。本章で見た「疑う役割を独立させる」設計は、同じ問い――何を、いつ、どういうモードで問うか――をコードを書く過程そのものに向けたものです。区別を要する境界は、コードの内側だけにあるとは限りません。
設計原則
原則: 大局的な疑いは、修正ループの外側に、独立したタスクとして切り出さなければ生まれません。
適用条件: 個々の変更がその都度局所的に正しく、テストも通り、既存の挙動も壊していないのに、積み重なった結果全体を疑う機会だけが存在しない場合に働きます。「次の不具合を直す」というタスクの型を繰り返しているだけでは、この機会は生まれません。
実装の形: まず「もう要らないのではないか」という仮説を明示的に立て、安全策を一時的に外すリスクを引き受けます。次に、その仮説を診断フラグとして分離し、実機での長期ソーク・恒久化・物理削除・副産物調査という段階を踏みます。あるいは、通常の修正タスクとは別に、対話相手に前提を疑わせることだけを目的にした対話を、意図的に設けます。
限界: この原則が保証するのは、疑う機会を構造的に用意することだけです。何を疑うべきかという矛先そのものは、原則の外にあります。また、疑うモードを渡す相手が変わったときにどこまで効果が上乗せされるかは、この一件の観察だけでは分離できません。
第14章:二つの手が、同じ場所を書き換えていた
2026年7月9日未明、75933a0というコミットが記録されました。20分後に3a0907cという別のコミットが、さらに10分後に3つ目のコミット40571adが積まれました。3つとも同じリポジトリ、同じ作業ツリーへの記録です。ところが、最初の2つを書いた手は、互いの存在にまったく気づいていませんでした。
これまでの章では、Windows APIやIMEという、awaseの外側にある相手が信頼できるかどうかを問うてきました。本章で問うのは、awaseを書いている手の側です。同じファイルを、同じ判断を、複数の手が別々の時に書き換えるとき、何を区切りとして立てればよいのか。答えは一つではなく、事件によって形を変えます。
git addが、知らない変更を拾いかけた
75933a0はBUG-21(Chromeのcold-start復帰処理の過剰発火)の修正、3a0907cはBUG-22(MS Edgeでのモード固着)の修正でした。症状も原因も別で直すファイルも別々でしたが、両者ともdocs/known-bugs.mdという1つのファイルに、自分のバグの節を末尾追記する形で書き込んでいたため、内容としては競合しませんでした。
問題は、この2つの修正を追っていたのが、別々のClaude Codeセッションだったことです。どちらも同じ作業ツリー上で、互いに気づかないまま並行して動いていました。docs/known-bugs.mdが衝突しなかったのは20分の間隔を空けて逐次コミットされたからにすぎず、同時にgit addしていたら、どちらかの追記が失われてもおかしくありませんでした。
| 時刻 | コミット | 内容 |
|---|
| 02:17:45 | 75933a0 | BUG-21修正(Chrome cold-start過剰発火) |
| 02:37:43 | 3a0907c | BUG-22修正(MS Edge conv=Eisu固着) |
| 02:47:35 | 40571ad | worktreeルールの明文化 |
わずか30分の間に、3つのコミットが積み重なっていました。危うい場面もありました。一方のセッションが変更したファイルを指定してgit addしようとしたとき、git statusにはもう一方のセッションのまだコミットしていない差分まで混ざって見えていたのです。指定を誤れば他人の作業中の変更を自分のコミットに巻き込むところでした。片方がgit checkoutやgit stash、git resetを実行していれば、もう片方の作業中のファイルを強制的に書き換えていたかもしれません。3つ目のコミットが積まれるところで踏みとどまりましたが、次も同じように踏みとどまれる保証はありませんでした。
一つの作業ツリーは、一人だけの前提で動いていた
gitという道具自体は、複数の書き手が同時に触ることを想定して作られています。ブランチを切り、変更をコミットし、後でマージする——ただしこの手続きは、それぞれの書き手が別々の作業ディレクトリを持つことも前提にしています。一つの作業ディレクトリの中で同時に2人が手を動かし、git statusやgit addをリアルタイムに共有する状況は、gitの想定の外にありました。人間が手作業で開発していた間この前提が崩れなかったのは、単に人間が同時に2つのセッションを起動しなかったからにすぎません。ルールが守っていたのではなく、偶然が守っていたのです。
この偶然は、AIエージェントが並行して複数のセッションを起動できるようになった時点で、成り立たなくなりました。git statusやgit addは、セッションを識別する仕組みを持っていないからです。
事故が起きかけた10分後、40571adというコミットで、新しいルールが書き加えられました。セッションやタスクごとに専用の作業ディレクトリと専用のブランチを用意する、というルールです。git worktree addを使うと、一つの.gitリポジトリを共有したまま作業ディレクトリだけを複数に分けられます。片方がcheckoutやresetを実行しても、別の作業ディレクトリのファイルには影響しません。ブランチも分かれているため、片方の作業がもう片方の意図しないマージ対象に混ざることもなくなります。
このルールには、線引きもありました。ユーザーが「ここで作業してほしい」と明示した場合や、一つのセッションの中で複数のタスクを順番にこなす場合には、わざわざ作業ディレクトリを分ける必要はありません。同時に動いていない限り、競合は起こらないからです。ルールを適用すべきなのは、複数のセッションが自律的に並行して動く場合に限られます。守るべき条件を絞り込むことで、「常に分けろ」という重い規約ではなく、必要な場面だけに効く軽い規約になりました。
worktreeを分けたことで、何が変わったのかを図にすると、変化の位置がはっきりします。

分かれたのは.gitではなく、作業ディレクトリの方です。オブジェクトデータベースは共有されたままなので、コミットの共有やマージは以前と変わらずできます。変わったのは、片方のcheckoutやstash、resetが、もう片方の手元のファイルにはもう届かなくなった、という一点です。
worktreeの衝突は、この章で扱う「二つの手」の一方にすぎません。もう一つの衝突は、まったく違う形で現れます。
同じキーを、二つの手が、別々の理由で退けていた
この20分間の衝突は、空間の衝突でした。同じ場所を、同じ時刻に、二つの手が書き換えようとしていたのです。もう一つの「二つの手」は、これとは違う形で現れました。同じ日には起きていません。約6週間かけて、少しずつ形を変えながら現れた衝突でした。
舞台は、TSFネイティブなアプリ(Windows Terminalなど、Text Services Framework経由でIMEと通信するアプリ)で、GJI(Google Japanese Input、Google 日本語入力)をDirectInput(直接入力)に切り替える処理です。どの仮想キーを送れば「真のIME OFF」になるかは一意には決まらず、2026年5月22日から7月2日までの5日間・6回、採用と撤回が繰り返されました。反転が起きた技術的な経緯そのものは第12章で見た通りです。ここで問うのは、なぜ反転したかではなく、反転のたびに何が記録され、何が記録されなかったかです。
最終的に収束したという結果だけを見れば、遠回りはしたが解決した、で済む話に見えます。しかし途中で何度も同じ結論に別々の手が独立にたどり着き直していた、という事実は、収束したことでは消えません。反転の中でも際立って対照的なのが、6月28日に起きた2つの撤回です。未明2時35分の098c663は、前日採用したVK_DBE_ALPHANUMERICを撤回するコミットでした。昼13時18分の9c3f11eは、別の仮想キーVK_IME_OFFへの切り替えを試み、その過程でVK_DBE_ALPHANUMERICが実は「半角英数」を意味しIMEはONのままだったという事実に行き当たります。しかしこの9c3f11eも、13時23分の668a131として数分後に撤回されました。
「なぜ戻したか」が書かれた記録と、書かれなかった記録
2つの撤回コミットを並べると、決定的な違いが見えます。098c663のコミット本文には、こう書かれていました。
TsfNative+GJI の IME OFF に VK_DBE_ALPHANUMERIC を使う変更(前コミット)がフォーカス変更時等の spurious apply_ime_open(false) を即時発火させ、GJI が予期せず直接入力モードに切り替わるリグレッションを引き起こした。F22 はコールド時 ~750ms かかるが、その遅延が spurious OFF の実害を防いでいた。
どのアプリで、何が起き、なぜ元に戻すと直るのかまでが具体的に書かれています。一方、668a131のコミット本文は、これだけでした。
This reverts commit 9c3f11e212252521b14f1a92fb33ddd9fbb73949.
git revertが自動生成する定型文だけです。9c3f11eが見つけた「半角英数(IME ON)のままだった」という事実そのものは正しかったはずですが、VK_IME_OFFへの切り替えがなぜ数分で撤回されたのかはどこにも書かれていません。新しい発見があった直後の判断が、理由を残さないまま消えていました。コミット履歴には「採用した理由」しか残らず、「却下した理由」は記録の外に置き去りにされていました。
採用の記録と、却下の記録は、別の記録だった
この反転劇を機に、2つの記録の仕組みが導入されました。一つは、試行そのものを1行ずつ記録するログです。2026年7月5日、d654f0fというコミットでdocs/experiments.mdが新設され、日付・仮説・環境・変更・観測結果・判定・コミットという7列の表に、1回の試行を1行として書き加えていきます。VK_DBE_ALPHANUMERICをめぐる反転の全経緯は、この形式で遡及的に書き起こされました。
このログには、一つ特徴的なルールがあります。判定が後日ひっくり返っても元の行は消さず、新しい行を下に足すだけです。反転そのものを履歴として残すことに意味がある、という考え方が、この「消さない」という一点に表れています。今日「正しい」と判定したことが来週覆るかもしれませんが、いつ・どんな根拠で・どちらの判定を下したかという記録さえ残っていれば、次に同じ提案が浮上したとき、以前の判定に立ち返ることができます。
534051aの行には環境欄「Windows Terminal × GJI × 約80秒idle」、観測結果欄「即時OFFにはなった」とだけ書かれ、次の行には暴発の疑いと098c663で戻した理由が続きます。短い記述でも並べて読めば、なぜ最初の判断が覆ったのかが後から来た読み手にもたどれます。
もう一つは、コミット本文そのものに対するルールです。IMEの制御やwarmup、キー選択に関わる変更をrevertするときは、どのアプリで・どのIMEの状態で・何をしたら何が壊れたかを本文に必ず書く——この3点を欠かさない規約が.claude/rules/experiment-logging.mdに加えられました。098c663は満たす良い例、668a131は満たさない悪い例として、ルールの中にそのまま引用されています。
このルールにも及ぶ範囲の線引きがあります。挙動が変わる修正のrevertにだけ適用し、リファクタやドキュメントだけのrevertには適用しません。すべてのコミットに同じ重さの記録を求めれば規約自体が守られなくなるためで、守るべき場面を絞り込む発想はworktree分離の線引きと同じでした。
なぜこの規約が必要だったかは、ルールの文中に率直に書かれています。「revert は『失敗の証拠』であり、その証拠を捨てないための規約。」失敗を記録に残さなければ、同じ失敗を、別の手が、また一から発見し直すことになります。
実験ログとrevert規約は、別々の場所に作られていますが、生まれた背景は一つです。「採用した」という記録だけでは片手落ちだという同じ反省から、俯瞰用の記録(ログ)とその場で出会う記録(revert本文)の両方が生まれました。両方があって初めて、「前にも同じことを検討した」という事実に、後から来た手が気づけます。
空間の衝突と、時間の衝突は、同じ形をしていた
2つの事件は、一見すると別の種類の失敗に見えます。worktreeの衝突は、同じ瞬間に起きた、複数の手の間の衝突でした。VK_DBE_ALPHANUMERICの反復は、隔たった時刻に起きた、過去の自分と今の自分の間の衝突でした。しかし根にある問いは同じです。「この場所を、今書き換えてよいのは誰か」「なぜ今の形になっているのか」という、所有と経緯の情報が、どちらの事件でも、暗黙のうちにしか存在していませんでした。worktree分離は空間側のこの問いに、実験ログとrevert規約は時間側の同じ問いに、それぞれ規約ではなく構造や記録として答える仕組みです。世代や出所を区別するという発想が、awaseのコードの中だけでなく、awaseを書くという行為そのものにも及んだ例だと言えます(第9章のエポック、第11章の証拠の試行帰属と同じ発想です)。
worktree分離も、実験ログも、revert規約も、破られないことまでは保証しません。.claude/rules/に置かれたルールも、docs/experiments.mdのログも、読まれて初めて効果を持ちます。読まれなければ、あるいは読んだ上で見落とされれば、区切りは紙の上にしか存在しません。その文書が実際に守られているかどうかを、機械が確かめてくれるわけではない、という一点が、まだ残っています。
他分野への転用
同じ資料を複数の人が同時に編集するツールでは、この問題はもっと日常的な形で現れます。GoogleドキュメントやFigmaが今どこを誰が触っているかをカーソルやアバターで表示するのは、空間側の区切りを規約ではなく画面上の情報として提示する工夫です。Wikipediaの「別の利用者がこのページを編集しました」という警告も同じ狙いを持っています。
時間側の区切りにも、よく知られた慣習があります。ソフトウェアの設計判断を記録するADR(Architecture Decision Record)は、採用した案だけでなく、検討した末に却下した案とその理由を書く欄を持ちます。なぜその案を選ばなかったかという記録を、コミット履歴の外に探しやすい形で残すための工夫です。
どちらの工夫にも共通する代償があります。カーソル表示にせよADRの却下理由欄にせよ、区切りを明示するための情報は誰かが手間をかけて残さない限り生まれません。記録を残す設計は、記録を書く側の規律に頼っています。規律が続く保証は、記録の仕組み自体からは出てきません。
設計原則
原則: 同じ場所を書き換える権利は、空間か時間のどちらかの軸で明示的に区切ります。
適用条件: 複数の書き手(人間とは限らず、複数のAIセッションも含む)が同じ対象に触れる可能性がある場面で働きます。実際に衝突したかどうかは問題ではありません。衝突しうる状態を、規約や注意力だけに頼って放置していないかが問われます。
実装の形: 対象が空間的に同時に触れられるか、時間的に隔たって繰り返し検討されるかで変わります。前者には、作業単位ごとに専用の場所を割り当て、構造的に交わらないようにする分離が効きます。後者には、判断を下した理由だけでなく、判断を退けた理由までを、消さずに記録として残す仕組みが効きます。
限界: この原則は区切りの手段を用意するだけで、その手段が使われ続けることまでは保証しません。作業ディレクトリを分けるルールも、revert本文に理由を書く規約も、守るかどうかは書き手の規律に委ねられています。文書は、読まれ、従われて初めて効果を持ちます。読まれなかった場合や、読んだ上で省略された場合に何が起こるかは、この原則の設計対象には含まれていません。規律が続かない場合にどうするかは、この原則の外側に残された問題です。
第15章:一度も走らなかったテスト
15件、実機で初めて赤くなった
2026年7月25日、crates/awase-windowsのcargo test --libが、Windows実機の上で初めて実行されました。それまでこのテストは、Linux上でのクロスコンパイルチェック(--no-run、コンパイルが通るかどうかだけを確認し、実行はしない方式)しか通っていませんでした。GCP(Google Cloud Platform)上にSpot(低価格だが強制停止されうる)インスタンスを立て、GitHub ActionsのセルフホストランナーとしてWindows実機を用意したことで、この状況が初めて変わりました。
結果は15件の失敗でした。この初回実行は、mutants-windowsというジョブの一部として行われました。cargo mutants(コードの一部をわざと壊した「ミュータント」を大量に作り、テストがそれを検出できるかを測るツール)のbaselineフェーズ、つまりミュータントを何も入れない元のコードでテストを走らせる最初の段階が、実質的に初の実機テスト実行でした。それまで積み重ねてきた「クロスコンパイルは通る」「clippyの警告はゼロ」という確認が、実行時の正しさをほとんど保証していなかったことを、この数字がそのまま示していました。コンパイルが通ることと、実行して正しく振る舞うことは、別の性質です。この章はその区別が、具体的にどこでどうねじれていたかをたどります。
15件の失敗は一様ではありませんでした。原因を洗い出すと、性質の異なる複数のグループに分かれていました。
| 分類 | 件数 | 中身 |
|---|
| 実装の欠陥(真のバグ) | 1件 | decide_alt_impersonationのKeyUp処理(後述) |
| テストの時刻設計の不備 | 3件 | 実sleep+実時刻で「過去」を作ろうとし、tick解像度(約15.6ミリ秒)に対してマージンが無かった |
| テストの入力値が古い | 1件 | 300msの判定窓の外にある値を使い続けていた |
| グローバル状態を守るロックの不備 | 複数件 | 3ファイルが同名だが別インスタンスのMutexを定義しており、排他になっていなかった |
| 古い実装を前提にしたアサーション | 1件 | 別のバグ(BUG-40)で仕様が変わった後、アサーションだけ取り残されていた |
この時点では、どれが実装そのものの欠陥で、どれがテストコード側の不備なのかは、まだ切り分けられていませんでした。「実機固有の何かがおかしいのだろう」という漠然とした見立ては、原因を一つずつ確認していく過程で大きく書き換えられることになります。
時刻設計の不備と300msの判定窓の不備は、どちらも実装ではなくテストコード側の見落としであり、実機で初めて走ったことで、ようやく可視化された不備でした。
書かれていたのに、一度も走っていなかった
15件のうち、実装そのものの欠陥だったのは1件だけでした。hook::alt_impersonation_tests::keyup_uses_the_decision_recorded_at_keydownというテストが、実機で初めて失敗し、decide_alt_impersonationという関数の欠陥を明らかにしました。
Left Altを親指キーとしてなりすまし設定している間、この関数はKeyDownとKeyUpの両方で呼ばれます。原因は、「今回のキー変換に使う判定」と「以後も保持しておくべき状態」を、同じ1つの戻り値で兼用していたことでした。KeyUpの時点でも、この関数は直前の判定(was_impersonating)をそのまま持ち越していました。しかしKeyUpの瞬間には、物理キーはすでに離れています。以後保持する状態は、この時点で必ずfalseに戻すべきでした。
この欠陥には実害がありました。判定結果はALT_L_IMPERSONATINGというフラグに格納され、3箇所のコードがmodifiers.altを強制的にfalseへ補正するかどうかの判断に使っていました。フラグがKeyUp後もtrueのまま残ると、なりすまし設定の無いRight Altを押す、あるいはAlt+Tabを行うといった、本来は無関係な操作まで誤って補正の対象になり得ました。修正は戻り値を二つの意味に分け、KeyUpの場合は「以後保持する状態」を常にfalseへ戻すという、ごく小さな変更でした。修正後、GCP Spot self-hosted runner上での実cargo test --lib -p awase-windows実行でパスすることが確認されています。
ここで押さえておくべきなのは、この欠陥を検出したテスト自体は、新しく書かれたものではなかったという点です。docs/known-bugs.mdの記録には「既存のテストがそのまま回帰テストになる(新規追加ではなく、既存テストが正しく通るようになった)」とあります。つまりこのテストは、以前からcrates/awase-windowsのソースツリーに存在していました。書かれてはいたのに、実行できる実機環境が無かったために、一度も走ったことが無かったのです。書かれた規律と、実行された規律の間には、実行環境という不可欠な一段があります。この段差を図にすると、次のようになります。

コミットされていることも、コンパイルが通ることも、実機で実行されて結果を確認できることの代わりにはなりません。この章の主題は、まさにこの一段でした。
残る14件のうち、最も手間のかかった原因は、ロックの不備でした。TSF_OBSというプロセス全体のグローバル状態を保護するはずのMutexが、observer.rs・probe.rs・literal_detect_fsm.rsという3つのファイルで、それぞれ別々のstaticとして定義されていました。TEST_LOCKという同じ名前が2ファイルに、VETO_TEST_LOCKという別名がもう1ファイルに存在していましたが、名前が同じでも実体は別のMutexインスタンスです。cargo testのデフォルトの並列実行の下で、あるファイルのテストが、別ファイルのテストによるTSF_OBSの書き換えに巻き込まれていました。3つの定義を図にすると、次のようになります。

observer.rs・probe.rs・literal_detect_fsm.rsはそれぞれ独立にMutexをstatic定義しており、コンパイラはファイルをまたいだ名前の重複を検出してくれません。GJI(Google Japanese Input、Google 日本語入力)は、TSF(Text Services Framework、Windowsの入力方式を仲介する仕組み)を介した状態確認(probe)によって、いま入力を受け付けられる状態かどうかを判定しています。この確認に関するgji_last_write_msが意図せず0にリセットされ、本来StaleConfirm(古い確認情報)になるはずの判定が、CompositionConfirmed(確定済み)に化けていたのです。
修正は一度では終わりませんでした。observer.rsにTSF_OBS_TEST_LOCKを1つだけ定義し、3ファイルともこれを共有する形に統一した後、再実行すると新たな失敗が現れました。ロックを一切持たずTSF_OBSを直接操作していたprobe_fsm.rsのテスト3件が、この統一作業そのものから見落とされていたのです。統一によって他のテストが正しく排他されるようになった結果、逆にこの3件の無防備さが表に出てきました。literal_detect_fsm.rs側のsleep依存の不備も同様に、ロック統一後の再検証で初めて顕在化しています。残る1件、decide_plan_nc_fired_enables_literal_when_gji_activeは、以前のバグ(BUG-40)で仕様が変わった後も古い意図を前提にしたアサーションのまま取り残されていたテストで、名前とアサーションを更新することで解消しました。
最終的に、計4コミット・3回の実機再実行を経て、15件の失敗はすべて解消しました。docs/known-bugs.mdは、この経過から次の教訓を導いています。
クロスファイルでグローバル状態を共有するテスト群は、1箇所直すたびに実機で再実行し、マスクされていた別の失敗が露出しないか確認するまで「直った」と判断しないこと。
一度に全部を見通せなかったのは、注意不足だったからではありません。ある失敗が、別の失敗によって隠されていたのです。隠していた側を直すまで、隠されていた側は姿を現しませんでした。
この一連の経過は、修正作業をAIエージェントに任せる場面での指示術としても読み替えられます。エージェントは1箇所を直し、テストが通ったことを確認すると、「直りました」と報告してきます。この報告は、その1件に関しては事実ですが、隠れていた別の失敗が存在しないことまでは保証しません。ロック統一によって初めて排他されるようになったprobe_fsm.rsの3件は、それまで別の不備の陰に隠れており、統一という1回の修正ではまだ検知されていませんでした。確認すべきは指摘した1件が直ったかどうかではなく、グローバル状態を共有する範囲全体を、直すたびに実機で再実行したかどうかです。1件のPASSは、その1件が直った証拠にしかなりません。
文書と、警告と、型と
decide_alt_impersonationの欠陥そのものは、修正すれば終わりです。しかし、なぜこの欠陥に気づくまでにこれほど時間がかかったのかという問いには、修正だけでは答えられません。rust-nicolaのリポジトリには、.claude/rules/fix-requires-evidence.mdという規律が、この事件より前から存在していました。warmup・focus遷移・IMEのbelief・conv modeへ・キー選択という、繰り返しバグが再燃してきた領域に触れるfixコミットは、回帰テストの追加か、docs/known-bugs.mdへの症状・再現手順の記録のどちらか一方を、同じコミットか直後の追随コミットに含めることを求めています。
この規律の背景には、実機の組み合わせ依存が強い領域では「直したつもり」が別の環境で再発しやすい、という経験があります。テストは機械可読な再発防止、docs/known-bugs.mdは人間可読な再発防止であり、どちらか一方を必ず添えることで、次に同じ領域を触る担当者が過去の知見にすぐたどり着けるようにする狙いでした。
この規律を補強する仕組みとして、pre-pushフックに軽量なチェックが入っています。対象ファイルが変更されているのにテストにもknown-bugs.mdにも差分が無いpushに対し警告を出しますが、ブロックはしません。純粋なリファクタなど意図的に不要な変更は、そのまま押し通せます。BUG-41のテストが一度も走らなかった事件は、この仕組みの外側で起きました。テストはコミットに含まれ--no-runのチェックも通っており、欠けていたのは規律を破る変更を止める仕組みではなく、書かれたテストを実際に実行する環境そのものだったのです。
この事件と同じ時期、rust-nicolaにはもう一つ、規律をより強く機械へ委ねようとする提案——ADR-082という設計文書——が持ち上がっていました。まだ提案段階にある構想であることを断っておきます。ADR-082が扱うのはjournal.rsという既存の仕組みで、ホットキー操作で直近2048件のイベントをダンプし、一部の純粋関数についてはこのダンプを再生して回帰テストとして固定化する基盤(journalリプレイ)が、この事件より前から用意されていました。ADR-082は、この基盤の対象を広げ、非同期に届く確認情報がどの世代の要求への応答かを取り違えるという繰り返されてきた欠陥を、個別に直すのではなくEventOriginという一つの型に集約し、「出所・世代の規律」をコンパイラに強制させることを提案しています。
このADRは、提案にとどまらず一部を実際に検証しています。EventOriginの最小実装(既存コードへの配線はしない)と、過去のバグ(第10章のBUG-43、675ミリ秒の間に同じ補正コマンドが16回連続送信された不具合)を題材にしたjournalリプレイのフィクスチャがLinux上のユニットテストとして書かれ、16回の連続検知のうち実際に送信されるのは上限5回までで残り11回は諦めに回るという有界終端を検証し、通ることが確認されました。ただし、この検証には明記された限界があります。用いたフィクスチャは実機のjournalダンプの転記ではなく、BUG-43発生当時actuation(実際にOSへ作用を送る処理)の呼び出しをjournalに記録する仕組み自体が存在しなかったため、docs/known-bugs.mdの記述から時刻を手作業で近似復元したものです。この限界は、ADR自身が「限界(重要)」として明記しています。
続く追記(「Phase 0.5」)では、actuation呼び出しを記録する専用のデータ型ActuationRecordが追加され、EventOriginが実行時の状態の一部に配線されています(cargo test -p awase-windows --libは169件すべてgreen)。ただしこれもLinux上のユニットテストの範囲にとどまり、本番ドライバへの配線は「ADR-081 Phase 1dへの申し送り事項」として未着手のまま明記されています。
つまりADR-082が示しているのは、規律を機械へ委ねる方向が有望だというところまでです。Windows実機での本番配線・実機ソークという段階はまだ通っておらず、BUG-41のテストと同じく、この提案自体も実機という最後の関門をまだ通過していません。
この限界には、後日、もう一段深い実例が見つかっています。IME状態の軸分解を検討したADR-088は、修飾キー押下中に何が起こるかという設計(トラックB)を5ラウンドのpre-mortemにかけても収束させられず、実機での計測(トラックD)も、合成キー入力がAPIとしては成功を返すのに実際には届かないという原因不明の現象で中断しました。純粋関数として切り出せる規律は網羅テストで機械的に守れますが、「修飾キー押下中にOSが何をするか」は原理的に純粋関数へ切り出せません。実機をまだ通っていないという段階の違いではなく、実機を通さなければ検証しようがない領域が存在するという記録です。
この区別は、この章の設計原則(規律は機械が検証するまで規律にならない)をもう一歩具体化します。検証が済んでいない箇所を洗い出すときは、「まだ検証していないだけ」と「その場所は原理的にそこでしか検証できない」を分けて記録すべきです。前者はスケジュールの問題ですが、後者は実環境へのアクセスを先に確保しない限り、いつまで経っても消えない負債になります。
他分野への転用
チェックリストの隣に、機械的なインターロックを置く
紙の作業手順書に「この工程の後に必ずこの点検を行うこと」と書いてあっても、それだけでは遵守は保証されません。忙しい現場では読み飛ばされ、省略されます。製造業の現場は、この弱さに対して、ポカヨケ(poka-yoke、うっかりミスを構造的に防ぐ工夫)と呼ばれる対策を発展させてきました。手順を守らなければ次の工程へ物理的に進めない治具や、部品の向きを間違えたら組み込めない形状のはめ込み穴などです。規律は、それを守ってくださいという呼びかけから、守らなければ機械が止まる・進まないという構造そのものへ、置き場所を移されます。
同名だが別実体だったMutexの話も、この対比に重ねられます。作業手順書に「共通のロックを使うこと」と書いてあっても、複数の担当者がそれぞれ別の場所に「共通のロック」を作ってしまえば、手順書の文言は守られたように見えて、実際には守られていません。名前が一致していることと、指している実体が一致していることは、文書を読むだけでは区別できません。実体が一致しているかどうかを機械に確認させて初めて、この種のすれ違いは検出できます。
設計原則
原則: 規律は、それを検証する機械が実際に動くまでは、まだ規律になっていません。
適用条件: テスト・型・自動チェックのように、規律を機械的に強制する仕組みを導入する場面すべてに当てはまります。仕組みを書いた時点と、その仕組みが実際に想定した環境で実行された時点は、別の達成として扱う必要があります。
実装の形: テストはコンパイルが通ることではなく、対象とする実行環境で実際に走り、結果を確認できることをもって「効いている」と判断します。文書化された規律には、pre-pushのような弱い機械チェックを添え、可能ならば型による強制へ段階的に格上げします。
限界: この原則自体、規律を機械へ委ねる仕組みを一度作れば十分だ、とは主張しません。ADR-082のように、型による強制を提案し、その一部を検証しても、実機という最後の環境を通るまでは、その提案もまだ「書かれただけ」の段階にとどまります。
第16章:それでも壊れる条件

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

二つ目は、決定が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つ増える番号。第5章で
導入した仕組みです)を照合しますが、これが守るのは「この完了通知を信じてよいか」という
一点だけで、「コマンドがすでに取り消せない形で送信済みである」という事実は照合の対象に
なっていません。ここで守ろうとしていたのは状態の一貫性であって、物理的な作用の発火可否
ではなかったのです。
この状態は、二つの独立したフェンシング機構(古い指示や古い通知を、無効なものとして
退ける仕組み)が並んでいる、と整理できます。観測の経路と、効果の経路とでは、保証の
中身が違います。
| 対象 | 現在の保証 | 守らないこと |
|---|
| FocusEpoch / AcceptedObservation(観測の受理) | staleな観測がbeliefを汚染することを防ぐ | ― |
derive_open()(利用時の再照合) | 呼び出しの都度、現在のエポックと再照合する | ― |
| ImeTransition.generation(効果の完了記録) | staleな完了通知がbeliefを上書きすることを防ぐ | OSへの送信自体が、staleな決定に基づいて既に発火済みであること |
Actuation.sent_at(drift correction経路) | 送信時刻による鮮度フェンスあり(実機ソーク未実施) | ― |
dispatch_ime_set_open(最終送出) | ― | dispatch直前の鮮度。送信済みOSコマンドは取り消せない |
観測を信じる前にエポックを見る。完了通知を信じる前にgenerationを見る。この二つは対称的な
防御に見えますが、まだ実行していない効果を実行する直前にもう一度照合するという三つ目の
防御は、どちらの機構にも含まれていません。表の上三行と下二行の間には境界線があります。
観測が信念を汚す経路は二重に守られ、drift correctionだけは鮮度フェンスを持つように
なりましたが、この節の冒頭で見たdispatch_ime_set_openという最終送出の一点は、まだ
一度も検査されていません。
なお、観測の経路にも隙間は残っています。GJI(Google Japanese Input、Google 日本語入力)・
TSF(Text Services Framework、Windowsの入力方式を仲介する仕組み)のようなイベント駆動の
観測はエポック照合の対象外で、3000ミリ秒の鮮度ウィンドウだけに守られています(同期
ポーリングのObserverPollには、この不一致は起こり得ません)。この隙間は、本章の主題である
効果経路の欠落とは区別しておくべきです。
「型で防げる」と言うとき、実際に防げている範囲を、こうして書き出しておく必要があります。
おおよそ塞がっているという手触りと、実際に塞がっている範囲は、別のものです。
「採用した」と「実機で確かめた」は、章ごとに進み方が違う
この境目は、この章だけの話ではありません。本書の後半で「採用した」と書いた工夫の多くが、
同じ境目を抱えたまま執筆を終えています。
| 章 | 工夫 | 状態 |
|---|
| 第10章 | FeedbackPolicy(Actuation) | 実装済み・実機ソーク未実施 |
| 第11章 | ESCと限定replayによるStage 2回収 | 未実装(検出のみ実装済み) |
| 第12章 | ImeProfileDriverの実配線・旧経路撤去 | 未着手(型設計と契約テストはLinux上で検証済み) |
| 第15章 | EventOriginの本番配線(ADR-082) | 未着手(Phase 0.5までLinux上で検証済み) |
| 第16章 | dispatch_ime_set_openの再照合 | 未実装 |
「未実装」と「実装済みだが実機で走っていない」は、別の状態として区別しておく必要があります。
前者はコードがまだ存在しません。後者はコードが存在していても、Windows実機という最後の
関門をまだ通っていません。本書が「採用した」と書いた工夫のうち、実機で確かめられたと
言えるものは、まだ一つもありません。
この自己監査そのものを裏づける出来事も、後から見つかっています。2026年8月、TsfNative
アプリへのフォーカス復帰時、実際には何もOSへ送信していないのに、appliedという記録を
Confirmed{open: belief}へ書き換えてしまう欠陥が見つかりました(ADR-098、BUG-69)。
beliefを、actuationが完了した証拠として書いてしまう、という同じ形の欠陥です。
docs/known-bugs.mdは、これをBUG-19・33・48・68に続く5例目の同一パターンと記録しています。
本章冒頭で見たFocusEpochが閉じたのは観測の入口であり、beliefと実際の作用の証拠を
取り違えないという規律までは、型の導入だけでは自動的に守られませんでした。
同じ形の欠陥が場所を変えて何度現れたか、時系列で並べると再発の重みが分かります。

5回という再発回数は、それ自体が一つの判断材料です。同じ形の欠陥が3回目・4回目と
別の場所で独立に見つかるなら、それはもう個別修正で足りる段階ではなく、「その形を
型で書けなくする」規律へ格上げすべきだ、というシグナルとして扱うべきでした。実機での
検証は2026年8月22日に1回だけ行われており、長時間のソークはまだありません。
まだ存在しない設計
先の表の下二行のうち、drift correction経路を除いたdispatch_ime_set_openを埋めるとすれば、
という以上の意味を持たない候補です。ここから先は実装済みの仕組みの説明ではなく、
awaseのコードにはまだ存在しない設計です。
Effectにも観測と同じように生成時点のエポックを持たせるという案がありますが、値として
持たせるだけでは、照合を呼び出す側が書き忘れればそのまま素通りしてしまいます。より強い
案は、dispatchの手前に通過しなければ送信できない関所を置くことです。観測の受理で使った
形を効果の送信にも当てはめ、AcceptedObservationに対応するAcceptedEffectのような型を
用意して、その関所を通った値しか送信関数に渡せないようにします。関所を型で強制しておけば、
送信経路が新しく増えても同じ抜け道は生まれにくくなります。
いずれの案も狙いは一つです。決定からOSへの送信までの間に生じる間隙で、もう一度いまの
エポックやgenerationと照合し、一致しなければ送信そのものを取りやめる。取り消せなくなる
直前の地点を、最後の検査点にすることです。もっとも、この検査点にも代償はあります。送信の
直前にもう一度照合すれば、その分だけ送信のタイミングが遅れます。フォーカスが変わる頻度に
比べてこの遅れがどこまで許容できるかは、実装してみるまで分かりません。
この2つの案のうち、値を持たせるだけの案に近い形は、drift correction経路にはすでに
一部入っています。第10章で見たActuation.sent_atという送信時刻が、収束確認の下限を
定める鮮度フェンスとして2026年7月25日に実装されましたが、Windows実機でのソークテストは
まだ実施されていません。一方、dispatch_ime_set_openという最終送出の一点には、この2つの
案のどちらも入っていません。第9章の82分とは違い、ここにはまだ答えがありません。
本書のここまでの章は、実際に起きた事故から学んだ工夫を扱ってきました。この章だけは、まだ
起きていない事故を、起きる前に設計で塞ぐという先回り型の指示のしかたです。同じ2026年7月25日に
起票されたADR-082(journalの構造化リプレイ基盤化)も、Claude Fable 5との壁打ちが起点でした
(第13章参照)。
他分野への転用
空いた駐車スペースを見つけてから、車を取りに戻るまでの間
空いている駐車スペースを見つけて、少し離れた場所に停めた自分の車を取りに戻る間に、別の車が
先にそのスペースへ入ってしまうことがあります。確認した瞬間には空いていたのに、使おうとする
瞬間には状況が変わっているということです。
この種の間隙は、ソフトウェアの世界では長らく知られてきました。「確認した時点」と「使う
時点」がずれることで起きる不具合は、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を引数として持たせます。送信の直前に、その値と現在の値をもう一度照合し、一致しない場合は送信そのものを取りやめます。
限界: この再照合を置いても、判定から送信までの間にさらに間隙が生まれれば、同じ問題は一段深い場所に移るだけです。境界をどこまで遡って検査するかは、有限の選択であり続けます。
終章:境界を一つ閉じるたびに

awaseの中にも、最後にコマンドを送り出す関数があります。名前はdispatch_ime_set_openといいます。関数の中身はわずかです。すでに確定していたopenという値を、そのままset_ime_open_cross_process_asyncへ渡す(await)だけです。
awaseには、値を使う直前にもう一度いまの世代と照合する仕組みが、随所にあります。観測を受け取るときも、観測を使うときも、この照合は繰り返し行われます。ところがdispatch_ime_set_openの直前にだけ、この照合がありません。openという値は、awaitに入る前の同期処理の中で一度確定した後、送り出される瞬間まで、二度と検査されません。守られている場所と、守られていない場所が、同じランタイムの中に隣り合っていました。
openという値そのものは、確定した時点ではたしかに正しい決定でした。フォーカスも、IMEの状態も、そのときの世界を正確に反映していたはずです。しかしawaitの間、制御はいったんイベントループへ戻ります。フォーカス変更を処理するイベントは、その間に先に実行されることがあります。数ミリ秒という短さは、人間の感覚では無視できる長さです。しかし取り消せない一歩を踏み出す側にとっては、無視してよい長さだという保証はどこにもありません。
第16章は、この空白を実際にコードを読んで確かめています。この手順を実際に踏んだという記録は、まだどこにもありません。それでも、これを止める仕組みが存在しないという事実だけは、確認できました。この空白が、awase固有の設計ミスなのかどうかは、章の終盤で改めて確かめます。まずは、そこへ至るまでに本書が実際に何を確かめてきたかを、ふりかえります。
二つの境目を、確かめに行った
はじめにで、本書を貫く問いをこう書きました。外の世界を相手にするソフトウェアを設計するとは、どこまで「同じ」とみなしてよいかを決める仕事だ、という問いです。この「同じという扱い」が崩れる境目は二つありました。一つは、遅れて届いた証拠をいま実行中の処理のものと見なしてよいかという、時間をまたぐ境目です。もう一つは、性質の異なる複数の相手を同じ一つのロジックで扱ってよいかという、空間をまたぐ境目です。二つの境目は症状としては別々の顔をしていましたが、根にあった問いは同じでした。
同じという扱いが崩れたとき、awaseは決まって同じ経過をたどりました。症状を一つ直しても、形を変えた同じ症状が別の場所で再発し、集約や共通化によって根本から解決しようとしても、その中でまた同じ混同が起きます。この繰り返しの果てに、ようやく「実は問いの立て方そのものが違っていた」という気づきに至ります。
時間をまたぐ同一視――観測は閉じたが、実行はまだだった
第9章は観測の受理という入口を、FocusEpochという世代番号とAcceptedObservationという型で閉じましたが、実行という出口の問いを残しました。第10章はこの出口に、結果を読み戻せるかどうかで補正ループの設計を変えるべきだという答えを出しています。第11章は、結果が戻ってきてもそれが今の試行のものとは限らないという問いを扱い、遅れて届いた証拠は世代を照合して初めて誰の手柄かが分かること、取り消し操作は被害範囲を基準に選ぶべきことを示しました。第16章は、これら三つの章が埋めた場所とまだ埋まっていない場所を一枚の表に並べ直し、信念が作用に変わり送り出される経路には送信直前の再照合がまだ存在しないことを、コードを読んで確かめています。
時間をまたぐ同一視という問いは、結局のところ一つの値では閉じませんでした。観測を受け取る場面、結果を待つ場面、証拠を受け取る場面、そして作用を送り出す場面という、少なくとも四つの異なる場面それぞれで、同じ問いを個別に確かめる必要がありました。一箇所を固く守っても、隣の場所が空いていれば、症状は形を変えて戻ってきます。四つの場面に共通していたのは、遅れて届く何かを、いま進行中の試行のものだと無条件に信じてしまう構図であり、それがどの世代・どの試行に属するかを先に照合することだけが、症状を最終的に食い止めていました。
空間をまたぐ同一視――共有か重複かは、軸を見極めてから決める
第12章が向き合ったのは、まったく別の壊れ方でした。IMEをオフにする正しいキーが、5日間で6回もひっくり返り、原因をたどると、実行中に変わらない静的な性質(アプリごとの観測能力)と、実行中に変わる動的な性質(いまどのIMEが動いているか)を、同じ一つの分岐の中に混在させていたことが分かりました。「共有すれば衝突し、重複させれば安全になる」という最初の仮説は、既知のバグ43件を実際に分類すると誤りで、共有が原因のバグは26%あった一方、分離が同期を怠ったために生まれたバグも見つかっています。答えは、共有か重複かという選択そのものではなく、変化する軸を先に見極めることでした。静的な軸は型として分離してよく、動的な軸は一箇所の共有機構にまとめ、権限を型で絞る。感覚ではなく、既存の不具合を実測で分類してから決めるという手順そのものが、この章がもたらした工夫でした。
空間をまたぐ同一視の教訓は、時間をまたぐ同一視の教訓と鏡のような関係にあります。時間の側は同じに見える値を世代というラベルで区別し、空間の側は違って見える複数の相手を、変化の性質に応じて型で区別するかまとめるかを実測で決める。この対応関係を図に整理すると、次のようになります。

どちらも、目の前の値をそのまま信じず、その文脈を先に問い直すという同じ態度に行き着きます。
疑う対象は、コードだけではなかった
第VII部、第13章から第15章は、同じ発想をawaseを書くという行為そのものへ向けました。第14章はworktree分離で空間側の区切りを構造として、実験ログとrevert規約で時間側の区切りを記録として明示しました。第13章は、積み上げた予防策を疑う機会が修正ループの内側には構造的に生まれないことを示し、疑うことを外側の独立したタスクとして切り出して初めて大局的な飛躍が起きました。第15章は、これらの工夫にも共有する限界があることを示しました——worktreeのルールも実験ログも、書かれた文書である以上、読まれて従われて初めて効果を持つという限界から逃れられませんでした。
それでも、実行直前の空白は別の場所にもあった
第16章が示した「実行直前の空白」は、awase固有の設計ミスではないかもしれません。それを確かめるために、著者が別に設計しているUnitree Go2 Runtimeを見てみます。
このランタイムにも、tickごとに単調増加するTickIdという値が、すでに存在します。構造としては、FocusEpochとよく似ています。しかし、コマンドを実行するexecuteという関数の直前にあるガードは、ウォッチドッグによる生存確認だけです。TickIdを、送信直前の再照合に使う仕組みは、まだありません。カウンタという道具はすでにそこにありながら、関所としては使われていない、という状態です。この設計は仕様書の段階で長らく止まっていたわけではなく、実装そのものはすでに完了しています。それでもなお、送信直前の再照合という一点だけが、実装が進んでも自然には埋まりませんでした。
この一致から確実に言えるのは、一つのことだけです。IMEという領域で見つかった構造上の空白と同じ形のものが、ロボットランタイムという別の領域でも、独立に見つかったという事実です。あらゆるPhysical AI(実世界で動作するロボットのようなAIシステム)ランタイムに共通する一般原則だとまでは、まだ言えません。確認できたのは一つの実装だけであり、二つの独立した事例が一致したという段階にとどまります。
どこまで一般化できるか
時間をまたぐ同一視も、空間をまたぐ同一視も、日本語入力エンジンに固有の問題ではありませんでした。遅れて届いた証拠がどの試行のものかを取り違える問題はフードデリバリーの配達報告や分散ロックの世界にも、静的な軸と動的な軸を混同する問題は権限管理や契約プランの出し分けにも、同じ形で現れます。これらは類推であり、確認済みの事実として書けるのは本書の中で実際に確かめた範囲までです。
Unitree Go2 Runtimeとの一致も、同じ注意が必要です。二つの独立した実装が同じ形の空白を持っていたという事実は、偶然にしては出来すぎているように見えますが、それ以上の一般化は今後の観察に委ねます。二つのプロジェクトは示し合わせたわけではありません。それでも、外の世界を観測し、取り消せない作用へ進むという同じ形の仕事をする以上、同じ場所に同じ形の空白が生まれること自体は、不思議ではないのかもしれません。
最初の一文字に戻って
本書は、最初の一文字がローマ字のまま残った、という異常から始まりました。原因は、変換ロジックの誤りではありませんでした。IMEがいまどういう状態にあるかを、こちらが正しく把握できていなかったことでした。
その後、長い年月をかけて積み重ねた開発の道のりは、この一つの事実を、さまざまな場面で繰り返し学び直す過程でした。観測は、常に少し過去のものです。実行は、常にいまこの瞬間に対して行われます。この二つを同じ値として扱ったとき、awaseは何度も壊れ、型として分けたときにだけ壊れ方が減りました。それでも、境界の外側には次の境界が残っていました。意図と観測を分ければ遷移と障壁が残り、観測の鮮度を守れば実行の鮮度が残り、静的な軸と動的な軸を分ければ、その分け方自体を疑う必要が残りました。時間の境界を閉じれば空間の境界が残る、この繰り返しが、本書が実際にたどった道筋でした。
awaseを書いていた当初、この問題を日本語入力エンジンに固有の癖だと考えていました。IMEという相手が、特に不誠実だからだと思っていました。しかし、この繰り返しを重ねたあとでは、そうは言えなくなりました。不誠実なのはIMEではなく、外の世界そのものでした。観測は届いた時点ですでに過去であり、それを疑わずに実行へ渡す設計だけが、繰り返し壊れていました。
外の世界を相手にするソフトウェアでは、観測したことと、いま実行してよいことを分けなければなりません。この一文は、IMEのために書いたものでした。しかし、この一文が指す境界は、時間の軸にも空間の軸にも、そしてIMEの外にもありました。
本書で追ってきたのは、一つのソフトウェアに積み重ねられた膨大なコミットそのものではなく、一つの区別が繰り返し必要になる場所の記録でした。その場所は、日本語入力エンジンの中にもあり、四本脚のロボットの中にもありました。次にどこで同じ形の空白に出会うとしても、確かめ方はもう分かっています。取消不能な境界の手前で、いま見ているものが、本当にいま扱ってよいものなのかを、もう一度だけ問い直すことです。
この区別を保つことの難しさは、本書を書く過程そのものでも一度顔を出しました。やまぶきの内部構造を調べていた際、調査に当たったAIが一度、確かめてもいない実装を断定調で語ったことがあります。指摘を受けて実際にバイナリを解析し直すまで、その一文は根拠のない推測のままでした。観測していないことを断定してしまう失敗は、コードの中だけでなく、コードについて語る言葉の中にも、同じ形で現れます。
設計原則
原則: 観測したことと、いま実行してよいことは、同じではありません。この区別は、一箇所を固く守るだけでは終わりません。観測を受け取る場面、結果を待つ場面、証拠を受け取る場面、作用を送り出す場面、そして性質の異なる相手を同じロジックで扱おうとする場面と、境界は一つではなく複数あります。
適用条件: 外部の状態を観測し、それに基づいて取消不能な作用を実行するすべての系に適用できます。単一のプロセス内で完結し、観測から作用までが同期的に進む処理や、作用を後から取り消せる処理には、通常あてはまりません。
実装の形: 境界ごとに異なります。観測には世代を刻み、使う直前に再照合します。実行結果には、読み戻せるかどうかで補正の設計を分けます。複数の相手には、変化する軸を見極めてから、型で分けるか一箇所へ集約するかを選びます。どの実装も出発点は同じで、値をそのまま信じず、その値がいつの、どの文脈のものかを、先に問い直すことです。
限界: この原則がすべての境界を保証するわけではありません。本書で示せたのは、awaseという一つのソフトウェアと、Unitree Go2 Runtimeというもう一つの独立した実装で、同じ形の空白が見つかったという範囲までです。境界をどこまで遡って検査するかは、今後もその都度、有限の選択であり続けます。あらゆる境界を一度に塞ごうとすれば、実装は終わりません。むしろ大切なのは、いま守っている境界がどこまでで、その外側にまだ何が残っているかを、正直に言葉にしておくことです。本書が繰り返してきたのも、その言葉にする作業そのものでした。
付録
本付録は、本文で語った事件を再説明する場ではありません。再利用可能な設計パターン、章とADR・コミットの対応関係、用語の定義、参考文献を、検索して参照するための資料としてまとめます。
全体像: 最終的なレイヤー構成と、そこへ至る流れ
事件ごとの詳細に入る前に、最終的にawaseがどんな形に落ち着いたかを俯瞰します。主経路
(入力から出力まで)は4段階を経て、以下の図が示す最終形に至りました。
| 段階 | 時期(章) | 追加されたもの | 主経路の形 |
|---|
| 第1段階 | 第1章 | なし(engine一つで完結) | 入力から出力までの素朴な一本道 |
| 第2段階 | 第1〜2章 | n-gramによる統計的タイブレーク、Decision/Effectモデル、アプリ種別ごとの出力切り替え | 一本の経路の中だけで完結 |
| 第3段階 | 第3〜8章 | 観測・信念層(観測は信念を直接上書きしない)、アプリ適応層(物理キーを渡すか制御) | 主経路の外側に別の層が新設される |
| 第4段階(最終形) | 第9章〜 | 観測・信念層と適応層を4層reducerへ統合、世代照合という型 | 「作用してよいか」を最後に一箇所で判断する経路が完成 |




出力の先には、メモ帳・Word(Win32/UWP)、Chrome・Edge、Windows Terminal・VS Codeという
具体的なアプリ名を置き、その先で実際にかな漢字変換を行うGJI(Google 日本語入力)・
MS-IMEという2つの競合IMEへつながります。フックからIME確定文字までの間、
WH_KEYBOARD_LL・IMM32/TSF・GetProcessIoCountersといったWindows APIのどこに
触れているかも矢印に添えました。
この経路は、送った命令が事実どおりに届いたか(柱A)、同じ分岐に押し込んでいた変化が
本当に一つの軸だったか(柱B)という2つの問いにまだ答えていません。第10〜11章は柱Aを、
第12章は柱Bを、第13〜15章は同じ照合の発想を開発プロセスへ向けます。
パターンカタログ
本文全体から抽出した12個の設計パターンです。各パターンは「パターン名・文脈・問題・働く力・解決・結果・限界・関連する章とADR」の8項目で統一しています。
1. Observer never overrides desired(観測は信念を上書きしない)
- 文脈: 非同期のprobe・pollがbeliefを更新しようとする場面です。
- 問題: 観測をそのまま反映すると、後から届いた古い観測がintentを消してしまいます。
- 働く力: 観測の遅延・順序入れ替えは避けられませんが、intentは観測より優先して守るべき情報です。
- 解決: Intent・Observation・Transition・Barrierの4層に分離し、Observationはbeliefを直接書き換えず、常にIntentとの照合を経由してから反映します。
- 結果: observationがintentを上書きして起きていた症状が解消しました。
- 限界: 4層分離自体は「いつ照合するか」を決めません。照合のタイミングそのものが、別の急所(パターン2)になり得ます。IME belief・GJI warmth・observation admissionの3領域が独立に同型の設計へ収束したのも、「集約すれば直る」という初期仮説が誤りだったことの傍証です。
- 関連する章・ADR: 第9章、ADR-032
2. Generation/epoch fencing(世代照合による足止め)
- 文脈: 非同期API呼び出し(IME apply等)の結果が発行順と異なる順序で返る場面です。
- 問題: 古い呼び出しの結果が新しい状態を上書きします(stale write)。
- 働く力: 完了順序は制御できませんが、「どの世代への呼び出しか」は発行時に分かります。
- 解決:
on_focus_process_changed(フォーカス先プロセスの変更)のたびにwrapping_add(1)でラップしながら増える世代カウンタ(FocusEpoch)を発行時に刻み、適用時に現在の世代と照合してから適用します。
- 結果: observation起因のstale writeを構造的に防止しました。
- 限界: admit()通過後から実際の適用までの間に世代が変わるケース(TOCTOU、確認した瞬間と使う瞬間のずれ)は、世代照合だけでは防げません。
- 他の実装での確認例: Unitree Go2 Runtimeにも
TickIdというFocusEpochに似た値がありますが、execute直前のガードはウォッチドッグの生存確認だけで、送信直前の再照合はまだありません(終章で触れます)。
- 関連する章・ADR: 第9章、第16章、ADR-077
3. Own the channel, don't filter the noise(チャネルを断つ、ノイズは濾さない)
- 文脈: 合成イベントと物理イベントが区別できない入力経路です。
- 問題: フィルタを重ねても新しいエッジケースが次々見つかり収束しません。
- 働く力: 判別は不可能な場合がありますが、経路そのものへのアクセスは制御できます。
- 解決: 判別を諦め、対象アプリに物理キーそのものを見せません(チャネルレベルでConsumeします)。
- 結果: 誤検知の再発が止まりました。
- 限界: チャネルを断てない構成(相手プロセスの内部ウィジェット構造など)では、別の観測手段が必要です。
- 関連する章・ADR: 第7章、ADR未確認(要補完)
4. Side-channel observability(副次観測)
- 文脈: 競合プロセスの内部状態を知る公式APIが存在しない場面です。
- 問題: DLLフック・COM傍受・IPC盗聴がいずれも失敗します。
- 働く力: OS標準のプロセスメトリクス(I/Oカウンタ等)は公式APIとして公開されています。
- 解決: 必要な操作自体をプローブとして転用し、
GetProcessIoCounters()の差分から相手の処理内容を推測します。
- 結果: 競合IMEの活性化状態を検出できるようになりました。
- 限界: この技法自体の一般的な限界は、2013年のアンチデバッグ手法の文脈でのみ間接的に確認されています。
- 関連する章・ADR: 第6章、ADR-048、ADR-062
5. Confidence-tiered sensor fusion(確信度による観測の融合)
- 文脈: 複数のprobeが矛盾する情報を返す場面です。
- 問題: 「最後に届いた値を正とする」と、信頼度の低い観測が高い観測を覆します。
- 働く力: observationの由来(evidence)によって信頼度は異なります。
- 解決: 観測源ごとに信頼度を重み付けし、投票的に1つのbeliefへ統合します。
- 結果: 確信度型の導入により、矛盾する観測がそのまま反映される事態を防ぎました。
- 限界: 重み付けの基準自体は経験則であり、新しい観測源が増えるたびに見直しが必要です。
- 関連する章・ADR: 第4章、ADR-044
6. Shadow-mode migration(並走移行)
- 文脈: 新しい実装(shadow_model等)を既存実装と置き換える場面です。
- 問題: いきなり切り替えると新実装の未知の欠陥が本番へ影響します。
- 働く力: 新旧を同時に動かして差分だけを見れば、切り替え前に欠陥を発見できます。
- 解決: 新旧実装を並走させ、差分ログで検証してから段階的に昇格させます(ADR-040)。
- 結果: shadow_modelをSSOT(Single Source of Truth、単一の真実源)へ安全に昇格できました。
- 限界: 並走期間中は、二重のメンテナンスコストがかかります。
- 関連する章・ADR: 第5章、ADR-040
7. FeedbackPolicy: 読み戻せるかどうかで補正ループを分岐させる
- 文脈: 自動是正ループを、観測能力の異なる対象(読み戻せる/読み戻せない)へ一律適用する場面です。
- 問題: 読み戻せない対象に確認前提のループを当てはめると、無限に再送するか収束を偽装します。
- 働く力: 読み戻せるかは実装者の好みでなく、対象システムの観測可能性で決まります。
- 解決:
Read { source, deadline }/Blind { max_attempts, backoff }という型を導入し、指定を省略できないようにします。Blindの諦め(GaveUp)は観測ストアへ書き込みません。count guardと組み合わせれば追加漏れも検知できます。
- 結果: 675ミリ秒に16回の無限再送と、観測を書き戻すことによる補正の永久停止という、正反対の2つの壊れ方を、同じ型設計で防止しました。
- 限界: この型は有限回で終端すること(liveness)は保証しますが、観測を書き込む側の偽装(safety)までは防げません。Windows実機でのソークテストも未実施です。
- 関連する章・ADR: 第10章、ADR-078、ADR-080、ADR-081
8. Blast radius(破壊範囲)で取り消し操作を選ぶ
- 文脈: 取り消し操作(backspace等)の対象を、遅れて届く証拠だけから正確に狙い撃てない場面です。
- 問題: 精度を上げる対策を重ねても取り違えリスクは消えず、誤って発火すると無関係な確定済みの文字まで消えます。
- 働く力: 取り消し操作には、破壊できる範囲が構造的に限定されているものとされていないものがあります。
- 解決: 精度でなく破壊範囲で操作を選びます。カーソル直前を無条件に消すbackspaceではなく、現在のcompositionスコープの外に届かないESCキーへ置き換えます。
- 結果: 誤って発火しても、確定済みの無関係な文字を巻き込まなくなりました。
- 限界: 範囲が構造的に限定された代替操作が存在する場合にしか使えません。代替が見つからなければ、精度側の対策に頼らざるを得ません。
- 関連する章・ADR: 第11章、ADR-079
9. 静的な軸は型へ、動的な軸は共有機構へ
- 文脈: 複数の実行文脈(アプリ×IME等)にまたがるロジックを一つの汎用ループへ共通化してきた場面です。
- 問題: 「共有か重複か」を直感で選ぶと、共有側はcross-profile spilloverを、重複側は実装同士の乖離というリグレッションを生みます。
- 働く力: ロジックが実は2つの独立した軸(静的な軸、動的な軸)を1つの分岐に押し込んでいたことが原因でした。
- 解決: 静的な軸(プロファイル)は型で分離し重複を許容します。動的な軸(アクティブなIME種別)は共有機構にまとめ、capability tokenで到達可能性を閉じます。count guardとcontract testが、単一窓口化までの中間解として機能します。
- 結果: 既存バグ43件を分類した実測データに基づき、限定的な分離(Limited Go)を選択しました。契約テスト5件が、実データから拾い出した失敗モードに一対一で対応する形で実装されています。
- 限界: 変化の軸が二つに分解でき、かつ直交している場合にしか使えません。軸を見誤れば、分離そのものが新しいバグの温床になります。単一窓口化・完全分離そのものの代替にはなりません。
- 関連する章・ADR: 第10章、第12章、ADR-081、ADR-082
10. 大局的な疑いを、修正ループの外へ独立させる
- 文脈: 個々の変更が局所的に正しくテストも通るのに、積み重なった結果全体を疑う機会が存在しない場面です。
- 問題: 「次の不具合を直す」を繰り返すだけでは、「今もこれ全部が必要か」という前提を疑う問いは生まれません。
- 働く力: 疑う機会は修正ループの延長では生まれず、独立したタスクとして切り出さなければ生まれません。
- 解決: 「もう要らないのではないか」という仮説を立て、安全策を一時的に外すリスクを引き受けるか、前提を疑わせることだけを目的にした対話(壁打ち)を設けます。
- 結果: 待機行列・捨て打ち機能という二重の予防的防御層の全廃(第13章)、プロファイル別ドライバへの分離という設計転換(第12章)という、2つの独立した大局的な飛躍が生まれました。
- 限界: 疑う機会を構造的に用意することは保証しますが、何を疑うべきかという矛先そのものは原則の外にあります。
- 関連する章・ADR: 第13章、ADR-080、ADR-081
11. 診断フラグ→実機ソーク→恒久化→物理削除の4段階
- 文脈: 「もう要らないのではないか」という仮説を、実害のリスクを冒さずに検証したい場面です。
- 問題: 予防策をいきなり削除すると仮説が外れた場合に実害が出ますが、放置すれば二重の保守コストが残ります。
- 働く力: 削除の可否を判断する証拠は、実機で長期間動かしてみなければ得られません。
- 解決: 安全策を診断フラグで無効化し、実機で日をまたいでソークし、問題が出なければ恒久化、最後に物理削除します。削除時の副産物(到達不能コード等)も調査します。
- 結果: 待機行列・捨て打ち機能という二重の予防的防御層を、安全に全廃できました。
- 限界: この型は、疑う対象がいったん仮説として明示されていることを前提とします。仮説を立てる最初の一歩そのものは、この型に含まれません。
- 関連する章・ADR: 第13章
12. 同じ場所を書き換える権利を、空間と時間の両軸で区切る
- 文脈: 複数の書き手(人間・複数のAIセッション)が同じ対象に同時、あるいは時間を隔てて触れる場面です。
- 問題: 所有と経緯の情報が暗黙のままだと、空間的な衝突と時間的な衝突の両方が起こり得ます。
- 働く力: 空間的に同時に触れられる対象と、時間的に隔たって検討される対象とでは、必要な区切りの形が違います。
- 解決: 空間側には、作業単位ごとに専用の作業ディレクトリとブランチ(
git worktree)を割り当て、構造的に交わらないようにします。時間側には、判断を退けた理由までを消さずに記録する仕組み(試行ログ・revertルール)を用意します。
- 結果: worktree分離により、複数セッションの作業ツリー共有事故を防止しました。試行ログとrevert規約により、5日間で6回反転したキー選択の経緯を追跡可能にしました。
- 限界: どちらの仕組みも、区切りの手段を用意するだけであり、書き手がそれに従うかどうかまでは保証しません。規律が続く保証は、記録の仕組み自体からは出てきません。
- 関連する章・ADR: 第14章
ADR・コミット対応表
章・事件・ADR番号・主要コミットの対応です。本文の再説明はせず、参照先として使う表です。
| 章 | 事件名 | 症状 | ADR番号 | 主要コミット | 現在の限界 |
|---|
| 第9章 | IME belief/SSOTモデルの変遷 | observationがintentを上書き | ADR-032, 077 | d2e183f, f8dd8d4, 6baabf9, a4db93e, 604cf99 | admit後・適用前のepoch遷移(TOCTOU) |
| 第9章(補) | 凝集性リファクタ第2幕(21タスク一括) | 循環依存・God Object・ImeBelief.input_modeのpub公開 | ADR-069 | d79c25e | 未確認(残タスクは個別コミットの追跡が必要) |
| 第9章(補) | GJI前置キー待機キューの所有権移管 | probe切り替え時にdeferred VKが破棄される(「にゅうりょく」→「にうりょく」) | ADR-071 | 15ac8d8 | StepCoroのself-priming漏れは別途対応 |
| 第10章 | conv-mode増幅ループ(ConvModeMgrの自己参照) | cold warmupのたびに誤ったbeliefがreal IMEへ再書き込み | ADR-078 | e7cc6d7, d495649 | 最小対策は第13章で撤去され、型設計は未実装 |
| 第10章 | drift correctionのActuation型化 | 675ミリ秒の間にVK_IME_OFFを16回無限再送 | ADR-080 | 68d73d5 | 実機ソーク未実施、暫定値のまま |
| 第11章 | per-VK confirmのstale confirm誤帰属 | 世代の異なる確定証拠をbackspaceが取り違え無関係な文字を消す | ADR-079 | 7b54a96, 1817925, a3be20d | ESC+限定replayによる回収(Stage 2)は未実装 |
| 第12章 | プロファイル別capabilityドライバへの分離 | 共有ループのプロファイル分岐がcross-profile spilloverを生む(既知バグ43件中11件) | ADR-081 | 123801b, 4af265f, bc3d13e | 実機配線と旧経路撤去は未着手 |
| 第12章(補) | journal配線によるActuation出所・世代の記録 | 分離移行中どちらの経路が真実か分からなくなる期間が生じうる | ADR-082 | 4503223, b1ea94c | 本体は提案段階、journal配線のみ実施済み |
| 第3章 | Windows Terminal/TSFコールドスタートliteral化 | ローマ字キーストロークのリテラル漏れ | ADR-049 | 26373e2, 84e6942, c089757, 97c922e, 6ecb8e9 | F2後の再初期化に344ms必要(idle時間に比例) |
| 第7章 | ImmCross/LINE偽VK_F3/F4 echo | 物理KANJIキー押下が偽トグル生成 | 要確認(未特定) | d99e3f1, 77ccf34, e890a26, f84c74b, 08b8661, 0e364ea | ImmCrossProbeの信頼性問題(Qt子ウィジェット) |
| 第7章(補) | GJIキーバインド3世代交代 | F13/F14衝突 | ADR-034/057/067 | 7f8291f, 81df62c, cfbbd20, b271aee, 098c663 | MS-IMEはVK_KANJIが必要(ADR-063) |
| 第9章(補) | VK/ScanCode混同検知dylint | newtype導入後も取り違え継続 | ADR-012 | 2dd43f4 | 未確認(現状lintで防御) |
| 第6章 | 捨て打ち機能(旧サクリファイシャル・ウォームアップ) | Chrome cold-start中のリテラル化 | ADR-048, 062 | 26bc0fe, d02ec44, 6c1732d, 22c3905 | vim等とのVK_A衝突(他アプリは離脱済み) |
| 第8章 | StepCoro統一 | 増殖したFSM群(5種類)の複雑化 | ADR-053 | e548e63, ccd4711, f01d401, b077c3d, 2c756dd, d1d6d17 | LiteralDetectFsmはコルーチン化されず残存(意図的) |
注記です。第7章のコミットハッシュ(11件)は実在・内容一致を確認済みですが、対応するADR番号は未特定です。
拡張用語集
src/toc.mdの用語集に、Unitree Go2 Runtime実地検証で見つかった対応語を追記しています。
| 用語 | 定義 | Unitree Go2 Runtime対応語 |
|---|
| 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)を通らないと構築できない、型で保証された「新鮮な観測」 | 現状Unitree Go2に相当機構なし |
| 捨て打ち機能 | I/Oカウンタ差分で副次観測する技法(第6章、第13章で撤去) | 直接対応なし |
| Observed<T> | Unitree Go2 Runtimeの観測品質型。Fresh・Stale・Unknown・Brokenの4値を持つ | awaseのshadow state・beliefに相当 |
| 用語 | 定義 |
|---|
| awase (合わせ) | 本書で描くWindows用日本語入力エンジン(NICOLA親指シフト方式) |
| NICOLA / 親指シフト | 親指キーとの同時打鍵タイミングで仮名を確定する日本語入力方式 |
| TOCTOU急所 | admitした時点では新鮮でも、実際に適用する時点では陳腐化している間隙 |
| FeedbackPolicy(Read/Blind) | actuateの結果を読み戻せるかを型で必須指定させる設計(第10章) |
| liveness / safety | 補正が有限回で収束する性質(liveness)と、状態が嘘をつかない性質(safety)(第10章、第13章) |
| 静的な軸 / 動的な軸 | 実行中に変わらない軸と、実行時に変わる軸の区別(第12章) |
| 世代フェンシング(epoch fencing) | 遅れて届いた証拠がどの試行に属するかを照合する仕組み(第11章、第16章) |
| blast radius(破壊範囲) | undo操作が届きうる範囲の小ささで操作を選ぶ考え方(第11章) |
| count guard / contract test | 呼び出し箇所数の凍結と契約テストによる現実的な中間解(第10章、第12章) |
| Unitree Go2 Runtime | 著者が別に設計しているロボット(Unitree Go2)向けアプリケーションランタイム |
参考文献リスト
| 文献 | 書誌情報 | 備考 |
|---|
| Kleppmann, フェンシングトークン論 | Martin Kleppmann, "How to do distributed locking", 2016-02-08, https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html | — |
| 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/ | — |
| 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 | (第9章) |
| Gaffer On Games | Glenn Fiedler, "Gaffer On Games"(ネットコード分野で広く参照される個人技術ブログ), https://gafferongames.com/ | 個別記事の直リンクは要検索です。 |
| 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 | — |
crossbeam-epoch | crossbeam-rsプロジェクト, https://docs.rs/crossbeam-epoch/ | epochという語は共通ですが別問題です(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ドキュメント | 世代カウンタとの組み合わせ例は未確認です。 |
| Peter Ferrie, アンチデバッグ手法集 | Peter Ferrie, "The Ultimate Anti-Debugging Reference", 2011年初出・以降改訂 | 原典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 | — |
未確認・要確認事項
- 第7章(ImmCross/LINE偽イベント)に対応するADR番号は未特定です。
- 参考文献のうち「要検索」と記した項目は、出版前に原典を直接確認してください。
- 第1・2・3・6・7・8・9・16章に追加した関連研究(フェンシングトークン、ゲームnetcode、
Rustの世代付き型、アンチデバッグ手法、受動的ヘルスチェック、労働安全の階層モデル、
チョードキーボード特許、Subsumption Architecture、ブラウザのイベントループ/Android ANR、
RAIIの他言語比較、Kubernetes readiness/livenessプローブ、TCP再送のKarnのアルゴリズム、
Redux、async/await、フライバイワイヤのフライトエンベロープ保護、CWE-367/TOCTOU等)の
書誌情報は、本セッションでWeb検索を伴わず記憶とmaster-research-logの記述から
記載したものです。URL・年号・巻号等は出版前に一つずつ裏取りしてください。