第5章:過去から届く観測

全体像の中でこの章が扱う部分: 観測・信念層(詳細は付録「全体像」を参照)

ALT+TABをしただけで、日本語入力が消える

2026年7月4日の朝、奇妙な報告がメモに書き付けられました。ALT+TABでウィンドウを 切り替えただけで、日本語入力がオフになっている。キーを押し間違えたわけではありません。 何も入力していないのに、IMEが勝手に閉じているのです。

原因はすぐに見当がつきました。ALT+TABのタスクスイッチャーが表示されている間、OSは 選択用のUIウィンドウ(XamlExplorerHostIslandWindowのような一時的な窓)へ、ほんの一瞬 フォーカスを移します。ちょうどそのタイミングで起動していた観測処理(ImmCrossProbe)が、 この一時ウィンドウを対象にIME状態を読み取り、「オフ」という値を返しました。当時のこの 観測は「高信頼度」の扱いを受けていたため、現在の信念(desired_open)へそのまま採用され、 IMEエンジンをオフにするカスケードが走りました。

奇妙なのはここからです。この観測自体は、読み取った瞬間には嘘をついていませんでした。 一時ウィンドウのIME状態は、確かにオフだったのです。問題は、その観測が届いた ときには、フォーカスはもう別のウィンドウへ移っていたということでした。過去のある時点 では正しかった観測が、届いた時点ではもう別の文脈の下にいるのに、時刻だけを頼りに現在の 状態として採用されてしまう。

時系列に並べると、観測の対象と、その結果が使われる時点のずれが見えます。

ALT+TAB中の一時ウィンドウを観測した結果が、フォーカスが移った後に「現在の状態」として採用される

最初は、見たことのない新種のバグに見えました。しかし調べていくうちに既視感がありました。 似た構造の問題には、すでに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日後でした。

改名の時系列: BeliefStoreからImeApplyLatchへは、わずか42分後だった

順番に見ていきます。最初の一歩はd2e183f(5月22日18:43)でした。コミットメッセージには こうあります。

refactor(Phase C): ImeBeliefStore で shadow_ime_on を Confirmed/Intended/Unknown に 型区別

この時点で、すでに「意図」と「実測値」を型で分けるという発想に到達していたことが わかります。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_onImeApplyLatchの残骸のうちどれを見ればよいか、 コードを読むたびに追い直す必要がありました。統合後は、見るべき場所は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 は機能したが、時間ベース競合という構造的な問題を抱えていた:

この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完了」というイベントが二通りの結末をたどる様子で見て みます。

同じ「probe完了」でも、発行時のEpochと完了時のEpochが一致するかどうかで、観測は採用にも棄却にもなる

分かれ目は「何ミリ秒かかったか」ではなく、「発行してから完了するまでにフォーカスが 変わったか」だけです。フォーカスが変わっていなければ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つ目の適用を 見落としたまま次の作業に進んでしまいます。


設計原則

原則: 観測には「いつのものか」が付いていないと、過去が現在を書き換えます。

適用条件: 非同期に届く観測や応答が、発行から到着までの間に、判断の前提となる文脈(フォーカス・セッション・接続先など)が変わりうる場面に適用できます。単一プロセス内の同期処理のように、発行と適用の間に文脈が変わりえない場面では、この原則を適用する必要はありません。逆に、文脈が変わりうるのに「めったに変わらないから」という理由でこの原則を省略すると、発生頻度が低いぶん原因の特定がかえって遅れます。

実装の形: 観測を発行した時点で文脈を表す世代番号を記録し、観測を採用する時点で現在の世代番号と照合します。一致しなければ棄却します。本当に必要だったのは集約場所ではなく、この観測は今の文脈でまだ有効かを照合する仕組みでした。時間(ミリ秒)で近似すると、負荷や遅延で簡単に破れます。世代(整数)で照合すると、時間に依存せず正確に判定できます。

限界: この原則は、観測を受け取る場面だけを保証します。観測を受理した後、実際に不可逆な作用へ進むまでの間に、文脈がもう一度変わってしまう場合があります。その間隙をこの原則はまだ塞いでいません。