第8章:フィルタでは勝てない

全体像の中でこの章が扱う部分: アプリごとの適応層(詳細は付録「全体像」を参照)

2026年5月27日、21時。手元のコミットログには、LINEというアプリ名だけが繰り返し現れていました。 LINEはQtで作られたチャットアプリで、awaseの利用者の間でも特に厄介な相手として知られていました。 物理的なKANJIキーを押すと、LINEの内部で偽のVK_F3・VK_F4トグルイベントが生成され、それが本物の 物理イベントと区別できなくなるという症状です。この章のVK_F3・VK_F4は、キーボード最上段にある ファンクションキーのF3・F4(Windows APIが定義する本来のVK_F3・VK_F4)とは別物です。実際に 送受信されていたのは、IMEを半角/全角に切り替えるための専用の仮想キーコードVK_DBE_SBCSCHARVK_DBE_DBCSCHARで、その値がたまたま16進数で0xF3・0xF4になります。当時のコミットメッセージが この2つを略して「VK_F3」「VK_F4」と書いており、この章でも読みやすさのためその記法をそのまま 使います。利用者から見れば、親指キーを一度押しただけなのに、 内部では二回のトグルとして扱われてしまう現象でした。しかも毎回同じ形で再現するわけではなく、 直前にどんな操作をしていたか、どのタイミングでキーを離したかによって、現れ方が変わりました。 その揺れの一つひとつを、その晩は順番に踏むことになります。

この夜、私はこの症状に4回フィルタを重ね、4回とも再発させました。それぞれの修正は、直前の修正が 見落としていた一点をピンポイントで塞ぐものでした。ところが塞ぐたびに、次の一点が現れました。 翌日未明、私はフィルタを強化することそのものをやめ、経路そのものを断つという、まったく別の 問いに切り替えました。この章で追うのは、その一晩の攻防と、問いの立て方が変わった瞬間です。

これまでの章では、Windowsが返す観測が信用できないという問題を扱ってきました。届いた値を そのまま信じず、belief(信念)として一段クッションを置く、という考え方です。しかし今回の 相手はOS自身ではなく、隣で動く別のアプリでした。LINEが生成する偽イベントは、Windowsの API仕様の問題ではなく、awaseの物理キー入力とLINE自身の内部処理が、同じ経路の上で 衝突しているために起きていました。観測を疑うだけでは足りず、経路そのものを見直す必要が あったのです。

二一時、「防止」は一度で終わるはずでした

最初のコミットd99e3f1(21:00)のメッセージは「ImmCross asyncパスで楽観的latchを設定しLINEの spurious VK_F3を防止」でした。ImmCrossというのは、awaseがIME状態をIMM32経由で相手プロセスに 直接書き込む方式を使うプロファイルの名前で、LINEはこの方式に分類されていました。IMM32とは、 Windowsに昔からあるIME連携の仕組みで、外部のプログラムが他のアプリのIME状態を直接読み書き できるAPIです。「asyncパス」 とは、その書き込みを相手プロセスの応答を待たずに進める経路を指します。応答を待たない分、 自分の側の状態と相手の側の状態がずれる余地も大きくなります。

私は当時、偽のVK_F3が発生するのは自分がKANJI操作を送った直後の短い時間帯に限られると 考えていました。だから、その直後だけ一度だけ抑制用のlatch(かんぬき)を立てれば十分だろうという 仮説を立てました。latchという名前が示す通り、この修正は「一度きりの短い窓を閉じる」という 発想に基づいていました。窓の外側で何かが起きるとは、このときはまだ考えていませんでした。 自分が送った操作の直後だけを疑い、それ以外の時間帯は届いた値をそのまま信じてよいという 考え方は、それまでの章で組み立ててきたbeliefの発想の延長線上にありました。

この仮説はすぐに崩れました。1時間半後の22時35分、77ccf34「ImmCrossアプリのKANJI KeyUpで spurious VK_F3を防止」というコミットが積まれています。latchが想定していなかったタイミング、 具体的にはKeyUpの扱いでも、同じ形の偽イベントが発生していたということです。KeyDownの直後だけを 見張っていたつもりが、KeyUpという別の入口が空いたままだったのです。私は対象をKeyUpにまで広げ、 これで発生源はようやく塞がったと考えました。

一晩で、同じ言葉が四回繰り返されました

しかし22時35分の修正も長くは持ちませんでした。日付が変わった0時05分、e890a26 「ImmCross asyncパスでVK_F4/F3 KeyUpをshadow-suppressしLINEのspurious VK_F3を防止」という コミットが積まれます。今度はVK_F3だけでなくVK_F4も抑制対象に加え、shadow状態(awaseが自前で 保持するIME状態の推定値)を経由する形に作り直しました。対象となるVKコードを一つ増やし、 判定の経由地点も一つ増やしたことになります。この時点で、深夜の作業はようやく終わったように 見えました。VK_F3・VK_F4の両方、Down・Upの両方を押さえたつもりだったからです。

ところがその1時間半後、1時39分にf84c74b「VK_F3 Down ON→OFF時にVK_F4 Upもshadow-suppressして LINE spuriousチェーンを断つ」というコミットが積まれます。VK_F3がONからOFFへ切り替わる、その 一つの遷移の段階だけ、抑制の対象から漏れていたということです。ここで初めて「断つ」という言葉が コミットメッセージに現れました。しかし実際にチャネルを断つ処理が書かれたのは、この次のコミット でした。「断つ」と書きながら、まだ何も構造的には断っていなかったのです。

四段階の背後にあった実装は、shadow_toggle_suppressed_vksという許可リスト(HashSet)への 登録でした。抑制したいVKコードが見つかるたびに、このリストへ一つずつ追加していく仕組みです。 リストへ追加するという実装の形そのものが、次のエッジケースが見つかるたびにまた一つ追加する、 という運用を招いていました。四つのコミットを並べると、同じ形の失敗が繰り返されていたことが 分かります。それぞれの修正が何を本物のイベントだと仮定し、LINEがどの反例でその仮定を破ったかを 見ると、範囲の広げ方には共通の型があります。

時刻コミット何を本物のイベントだと仮定したかLINEはどの反例でそれを破ったか
21:00d99e3f1自分がKANJI操作を送った直後の短い時間帯だけ、偽のVK_F3が来るlatchが想定していないタイミングでも同じ偽イベントが生成された
22:3577ccf34KeyUpの扱いを直せば、発生源はそれで塞げるshadow状態を経由しない経路でも、同型の偽イベントが観測された
00:05e890a26VK_F4もshadow-suppressの対象に広げれば、経路を網羅できるON→OFFへの遷移という一段階だけ、抑制対象から漏れていた
01:39f84c74b漏れていた遷移さえ塞げば、これでチェーンは断てる「断つ」と書きながら、実際にはまだ何も構造的に断っていなかった

四つの仮説を並べると、狭めていた軸がそれぞれ違うことが分かります。1回目は「いつ」(操作直後の 短い時間帯)を絞り、2回目は「どの操作」(KeyDownかKeyUpか)を絞り、3回目は「どのキー」(VK_F3か VK_F4か)を絞り、4回目は「どの遷移段階」(ONからOFFへの切り替わりの瞬間)を絞っていました。 毎回、直前の反例が突いてきた一点だけをふさぎ、その他の前提はそのまま引き継いでいたのです。

この4つの仮説を通して見ると、一つ塞ぐたびに次の穴が現れ、最後に経路そのものを断つ決断へ至る流れが見えます。

4回のフィルタ強化が新しい反例のたびに次段へ進み、最後は物理KANJIキーの構造的な遮断へ転換する

四つのコミットメッセージの語尾を追うと、「防止」「防止」「防止」ときて、最後だけ「断つ」に 変わっています。この言葉の変化そのものが、フィルタを重ねるだけでは足りないと私自身が 気づき始めていたことを示しています。同時に、対象範囲はVK_F3単体→KeyUp→VK_F4→特定の遷移と、 一つの修正のたびに一段ずつ広がっています。これは新しい原因が次々に見つかったというより、 最初の仮説の立て方そのものが狭すぎたことを、後から一つずつ思い知らされていた形に近いものでした。

この種の修正は、モグラ叩きと呼ばれる形をしています。一匹叩くたびに、その手応えだけを見れば 成功しています。実際、21時・22時35分・0時05分のいずれの時点でも、私はその晩の作業が 終わったと感じていました。しかし次のモグラが現れる場所は、前の一匹が出た穴のすぐ隣でした。 四段階を終えた時点で私が向き合っていたのは、個々の穴ではなく、モグラがいくらでも出てこられる 盤面そのものだったのです。盤面を変えない限り、フィルタをどれだけ足しても追いつきません。

フィルタをやめ、経路を渡さないことにしました

四回目の修正から20分後、1時59分に積まれた08b8661のメッセージは 「ImmCrossアプリでも物理KANJIをConsumeしspurious VK_F3/F4連鎖を遮断」でした。ここで問いの 立て方そのものを変えました。「届いたイベントが本物か偽物かを、どう見分けるか」ではなく、 「そもそも物理KANJIキーをLINEに渡さなければ、偽イベントを生成する材料自体がなくなるのでは ないか」という問いです。前の4段階が全て「見分ける」側の問いだったのに対し、これは 「渡す・渡さない」という別の軸の問いでした。

たとえるなら、噂を止める方法には二通りあります。広まった噂を一つずつ訂正して回るか、そもそも 噂の種になる情報自体を最初から渡さないか、です。LINEに物理KANJIキーの情報を渡さなければ、 LINEがそれをもとに独自のトグル状態を組み立てる材料自体がなくなります。

この切り替えに、劇的な発見や新しい調査は必要ありませんでした。必要だったのは、4回同じ形の 修正を積んだという事実そのものを、一歩引いて見ることだけでした。個々の修正は、その場では どれも筋が通っていました。しかし4回目を終えた時点で並べて見ると、範囲が一段ずつ広がる という同じ形の失敗が続いていることが分かります。20分という短い間隔は、次の一手を新しく 発見したというより、それまでの4回を並べ直した結果と考えるほうが実態に近いものでした。

key_pipeline.rsの該当箇所は次のようになりました(memory project_kanji_imecross_spurious_vk3より)。

let is_kanji_event = event.ime_relevance.shadow_action.is_some();
let suppress_physical = if profile.can_use_imm32_cross_process() {
    // ImmCross: KANJI 関連 VK は Down/Up 共に Consume
    is_kanji_event
} else {
    // Imm32Unavailable (Chrome/Edge): 従来通り shadow_toggle 発火時のみ
    shadow_toggled && !profile.should_pass_physical_key()
};

条件分岐の中身は、以前の4段階よりもむしろ単純になりました。ImmCrossプロファイルに該当する なら、KANJI関連のVKはDownもUpも問答無用でConsume(握りつぶす)します。タイミングや遷移段階を 一つずつ数え上げる代わりに、判定そのものを一種類減らしたのです。8分後の0e364eaでは、 不要になったRegistry機構(先ほどの許可リストへの登録の仕組み)を撤去しました。 shadow_toggle_suppressed_vksというHashSetと、 それを操作するregister_shadow_toggle_suppress()try_shadow_suppressed_keyup()という 関数群です。差分は7ファイルで+27/-89、正味62行の削減になりました。四段階かけて積み上げた 「見分ける」ための仕組みは、「渡さない」という一行の条件に置き換わった時点で不要になったのです。

この単純化は、テストのしやすさにも表れています。is_kanji_eventsuppress_physicalも、 eventという値を受け取ってboolを返すだけの純粋な関数です。実際にLINEを起動し、物理 KANJIキーを押して確かめなくても、eventを組み立ててこの関数に渡すだけで、四段階の仮説 それぞれが見落としていたKeyUpの扱いやVK_F4の抜け漏れを、テストコードの中で再現し直せます。 四段階のフィルタが実機での再現待ちを繰り返していたのに対し、渡す・渡さないという一行の 条件は、実機なしで検証できる範囲を大きく広げました。

なぜこの転換が可能だったのかは、当時のメモfeedback_immcross_owns_kanjiに残っています。 ImmCrossプロファイルは、set_ime_open_cross_processによって相手プロセスのIME開閉状態を すでに直接書き込んでいました。つまり、物理KANJIキーをLINE自身に渡す必要は、そもそも なかったのです。渡さなければ、LINEが偽イベントを生成する根拠自体が消えます。フィルタを 四段階重ねている間、私は「渡した後でどう見分けるか」しか考えていませんでした。しかし ImmCrossというプロファイル自体が、渡すかどうかを選べる立場にすでにいたのです。

許可リストへの登録という形と、プロファイルごとの真偽値一つという形の違いは、状態を持つか 持たないかの違いでもあります。shadow_toggle_suppressed_vksは、どのVKコードを抑制するかを 実行時に覚えておく必要がありました。新しいVKコードが対象に加わるたびに、登録処理を呼び、 リストの中身を増やしていく必要があったのです。一方is_kanji_eventは、そのイベント一件だけを 見て判定が終わります。前のイベントで何を登録したかを覚えておく必要がありません。四段階の フィルタが複雑になっていった理由の一つは、判定のたびに過去の登録状態を参照する仕組みを 選んでいたことにもありました。

最終的な挙動は次の表に落ち着きました。表中のTSF(Text Services Framework)は、IMM32よりも 新しいWindowsの入力方式管理の仕組みで、Windows Terminalのような一部のアプリはこちらを使ってIME処理を 自前で完結させています。

プロファイルKANJI DownKANJI Up
Imm32Unavailable(Chrome/Edge)shadow_toggle後にConsume通過(アプリは反応しないため問題なし)
ImmCross(LINE/Qt)shadow_toggle後にConsumeConsume(今回追加)
TsfNative(Windows Terminal)通過(TSFが処理)通過

ImmCrossの列だけがDown・Up両方でConsumeになっている点が、他の2プロファイルとの違いです。 同じ物理キーでも、相手アプリがIME状態をどう受け取るプロファイルかによって、渡すか渡さないかを 使い分けるという発想は、この一晩で初めて明文化されました。表の3行は偶然そろったものではなく、 「相手が物理キーを見て何をするか」をプロファイルごとに事前に決めておき、見せる・見せないを その場の判定ではなく設計として固定した結果です。四段階のフィルタが場当たり的な条件式の 積み重ねだったのに対し、この表は一度作れば以後の判定を必要としません。

Physical AIへの接続

動作を見た別のセンサーが、それを新しい入力だと誤認する

LINEが起こしていたのは、ロボティクスの世界にも似た構造の問題です。自分の関節モーターが 発する動作音を、同じ機体のマイクが拾い、それを新しい音声コマンドだと誤認識してしまう フィードバックループがその例です。センサーの側から見れば、モーター音も外部からの音声入力も、 波形としては区別がつきません。区別できないものを区別しようとしてフィルタの条件を 増やし続けるか、あるいはそもそもマイクにモーター音を拾わせない(機構的な遮音、あるいは 発話区間だけマイクを有効化するゲーティング)かは、awaseが一晩でたどった 「フィルタ強化からチャネル遮断へ」という経路と同じ選択です。どちらを選べるかは、 自分がそのセンサーやアクチュエータの入出力経路をどこまで所有しているかで決まります。 機体の設計段階からマイクの配置や有効化タイミングを選べるなら、遮断は現実的な選択肢に なります。既製のセンサーモジュールを後から組み込むだけの立場であれば、遮断という選択肢は 手元になく、フィルタを強化し続けるしかありません。

境界を持たない層では、フィルタしか選べません

この問題は、awaseが最初に直面したものではありませんでした。Firefox OSのIME層である Gaia(bug 1110030)は、合成イベントと物理イベントが区別できないという同型の症状に対し、 IsSynthesizedByTIPという判別用フラグを追加する方向、つまりフィルタを強化する方向で 対応していました。awaseと逆の選択です。両者を四点だけ並べます。

この違いは、どちらの設計が優れているかという話ではありません。ImmCrossプロファイルは、 set_ime_open_cross_processによって対象アプリのIME状態をすでに直接所有していました。だから 物理キーをそのアプリに渡さないという選択が可能でした。一方Gaiaは、任意のWebアプリの上で 動く汎用のIME層であり、個々のアプリの入力処理を所有してはいません。渡さないという選択肢が、 そもそも手元になかったのです。フィルタが正しいか、チャネル遮断が正しいかは、どの層が 物理入力を所有できるかによって決まります。所有できないなら、フィルタしか残っていません。

「排除は緩和に勝る」という原則は、この場面で初めて発見されたものではありません。小さな 子どものいる家庭を思い浮かべてください。「ストーブに近づいたら危ないよ」と繰り返し言い 聞かせる方法(注意で防ぐ対策)よりも、ストーブの周りに柵を置いて物理的に近づけなくする 方法(危険の原因そのものを取り除く対策)のほうが、確実に事故を防げます。労働安全の分野には、 この考え方を段階として整理した枠組みがあります(アメリカの労働安全衛生研究所NIOSHが示す Hierarchy of Hazard Controlsが知られています)。危険源そのものを取り除く「排除」を、 注意書きや保護具よりも上位に置く考え方です。

ソフトウェア設計の分野にも、似た発想があります。日付を選ぶ画面で、過去の日付を選んで しまってから「その日付は無効です」と後からエラーを出すのではなく、そもそも過去の日付を カレンダーの選択肢に表示しない、という作り方です。「あり得ない状態はそもそも表現できない ようにする」という言い回し(make illegal states unrepresentable、OCamlコミュニティで 広まった、ソフトウェア技術者Yaron Minskyの言葉とされます)は、この発想を型設計の言葉で 語ったものです。ただしこちらは値の表現方法についての原則であり、awaseが直面したような、 入力チャネルそのものをどちらが所有するかという問題とは、厳密には別の話です。

awaseの選択が独自だったのは、原則そのものではなく、「渡すか渡さないかを選べる立場に、 たまたま自分がいた」という条件を見極めた点にあります。同じ原則を知っていても、Gaiaのように 渡さないという選択肢そのものが手元にない場合は、フィルタを磨き続けるしかありません。

この境界にも、期限がありました

08b8661と0e364eaでの転換は、ImmCrossプロファイルが物理KANJIキーを所有できるという前提の 上に立っていました。しかしこの前提そのものが、後に揺らぐことになります。7月2日以降、 ImmCrossProbe自体の信頼性、具体的にはQtアプリが子ウィジェットごとに別々のIMM32コンテキストを 持つという問題が見つかりました。所有していたはずの境界が、実は思っていたほど単純ではなかった ということです。この判定は、のちにFocusEpoch/derive_open()というモデルへ統合されることに なります。渡す・渡さないを一度決めれば終わりではなく、その決定自体がどれだけ新鮮かを、 別の仕組みで確認し続ける必要があったのです。

「このアプリはKANJIを渡さなくてよい」という判定そのものも、突き詰めれば一つの観測でした。 一晩かけて構造的な答えを見つけたつもりでも、その答えが今も成立しているかどうかは、 また別に確かめる必要があったのです。フィルタから経路の所有へ問いを変えたことは、この夜の 答えとしては正しいものでした。しかし所有しているという判断自体を疑う必要が出てくるのは、 もう少し先のことです。


排除は緩和に勝る

合成入力と物理入力が構造的に区別できないとき、判定条件を増やすフィルタは行き詰まります。

この原則が働くのは、入力の発生源そのものを自分の層が所有しており、渡す・渡さないを 選べる場合に限られます。渡す前に握りつぶせる位置に、自分のコードが立っているかどうかが 分かれ目です。

実装の形としては、個々のケースを判定する条件式を増やすのではなく、対象を丸ごと Consumeする一本の分岐に置き換えます。条件を増やすたびにコードは複雑になりますが、 経路を断つ実装はむしろ単純になります。

ただし、この原則は自分がその入力経路を所有していない場合には適用できません。所有権が 自分の層になければ、区別できないものを区別しようとするフィルタだけが残された手段になります。 また、所有しているという前提自体も、時間が経てば崩れることがあります。