第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のべき等性設計にも転用できます。
限界: この原則は、範囲が構造的に限定された代替操作が存在することが前提です。そのような代替が見つからない場合は、精度を上げる方向の対策に頼らざるを得ません。