第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・メモリ等)の変化を外部から読み取れる場合に使います。判定に使う指標は、目的の操作とそれ以外の操作を区別できるものを選びます。対象の状態が短時間で頻繁に切り替わる場合ほど、時刻ではなく量で判定できる指標を優先します。
実装の形: 本物の操作にプローブを便乗させ、専用の観測用入力を新たに作りません。しきい値は実測した複数サンプルから中間値として決め、コード上は経験的な境界として扱います。判定に使う値の取得順序(いつ基準値を取るか)も、しきい値そのものと同じくらい注意して決めます。
限界: しきい値は対象プロセスの具体的なバージョン・環境に依存し、普遍的な定数として扱えません。対象の実装が変われば、しきい値の再計測が必要になります。対象が想定していない状態にまで一律に適用すると、判定の空振りによってかえって性能が落ちることがあります。