第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する一本の分岐に置き換えます。条件を増やすたびにコードは複雑になりますが、経路を断つ実装はむしろ単純になります。
限界: この原則は自分がその入力経路を所有していない場合には適用できません。所有権が自分の層になければ、区別できないものを区別しようとするフィルタだけが残された手段になります。また、所有しているという前提自体も、時間が経てば崩れることがあります。