第4章:「わからない」を値にする

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

DecisionExecutorという構造体には、次のようなフィールドがありました。

applied_snapshot: Option<(bool, u64)>

RustのOption型は、値の「存在する」場合と「存在しない」場合を型として区別する仕組みです。 ここでのboolはIMEのONかOFFか、u64は確認時刻を表す、一見ただの値の組です。しかし 当時のADR-044は、この一行が実際には三つの状態を同時に表していたと記録しています。

None                 // フォーカス直後・起動時 — 実 IME 状態が完全に不明
Some((v, 0))         // 楽観更新 — async 完了前の事前書き込み。未確認。
Some((v, ts))  ts>0  // 確認済み — 実 apply 完了後。信頼できる状態。

読み取るべきことは一つです。tsという同じ整数のフィールドが、「まだ確認していない」 「仮に書いただけ」「確認済みである」という、性質の異なる三つの意味を一人で背負っていました。 ts == 0は、コードのどこにも明文化されていない、コメントだけが知っているセンチネル値でした。

「ts = 0」が三つの意味を一人で背負っていた

この構造がなぜ生まれたのかを理解するには、IMEの状態がどう更新されるかを見る必要があります。 awaseは、ユーザーの操作に応じてOS側のIMEをON/OFFする際、確認を待たずまず自分の側の値を 先に書き換えます。確認を待ってから次のキー入力を処理すると入力への反応が遅れて見えるため、 確認前の値を先に仮置きする設計そのものは妥当でした。そのためapplied_snapshotは、 「まだ何も書いていない」「書いたが確認していない」「書いて確認も取れた」という三段階を 一つの型の中に同居させていました。

タプルの第二要素であるat_msは、本来「確認できた時刻」を記録するためのフィールドでしたが、 確認前の値を仮に置く必要があったため、0という値が「まだ確認していない」の代役を兼ねる ことになりました。整数としての0と、意味としての「未確認」が、型としては区別されないまま 同じ場所に同居していました。

この同居が厄介なのは、0という値そのものが特別に見えない点です。at_ms > 0という条件式は 「時刻が記録されている」としか語らず、「確認済みかどうか」を読み取るには書いた本人が コメントを残すか、後から読む人がADRを掘り起こすしかありませんでした。

さらにADR-044は、実際の判断が三段階だけでは済んでいなかったことも記録しています。Effectの 発生源/IMM32によるクロスプロセス検出の可否/GJI(Google Japanese Input、Google 日本語入力)の健全性/適用したい方向/確信度/経過時間 という六つの軸の組み合わせが、単体テストの難しい40行ほどの条件分岐に埋め込まれていました。 六つの軸をif文の連鎖だけで場合分けしようとすると、組み合わせ爆発を避けられません。ts一つに 三つの意味を持たせた構造は、この六軸の判断が一箇所に集約されないまま、呼び出し側ごとに 書き直されることの温床にもなっていました。

同じ整数やboolに複数の意味を重ねて持たせる書き方は、awaseに限った習慣ではなく、境界条件の 判定を誤らせる典型的な原因の一つです。applied_snapshotの場合、その誤りがIMEの状態という 利用者の目に見える挙動へ直接つながっていました。

「IMEはboolで十分だ」という前提が崩れた

applied_snapshotが複雑化する前、awaseの前提はもっと単純でした。IMEの状態はONかOFFかの boolであり、awase側でキャッシュしておけば十分だという前提です。この前提はしばらく機能して いましたが、Win11のメモ帳やChromeのようなModern UIアプリで崩れました。IMM32は、Windowsが 古くから用意している、IMEとやり取りするAPI群です。旧来のWin32アプリでは IMM32によるクロスプロセス検出がおおむね素直に働き、awase側のキャッシュとOS側の実際の値が 食い違う場面はまれでした。前提が単純なままで済んでいたのは、対象アプリが単純だったから にすぎません。

ADR-005によれば、これらのアプリではクロスプロセスIME検出が常にopen=0を返します。実際には IMEがONであっても、OS側の検出APIは「OFF」としか答えません。この検出の失敗を吸収するために、 awase自身が推測を保持するshadow stateへのフォールバックが導入されましたが、更新する仕組みが まだ整っておらず、「IMEをOFFにしてもNICOLA変換が継続する」という一次バグが発生しました。 画面上の表示はOFFなのに、キー入力は同時打鍵の変換規則で処理され続けるという、利用者から見れば 表示と挙動が食い違う状態です。キャッシュする値そのものが正しくても、その値が「いつ、何によって 確認されたか」を保持していなければ、古いキャッシュと新しい現実の区別がつかなくなります。

この問題を掘り下げる過程で、ADR-029は既存の類似ツール(AutoHotkey/zenhan/alt-ime-ahk/ Keyhac等)も調査しています。IMM32はスレッドローカル設計であり別プロセスのIME状態を覗こうと すると信頼できない値を返しやすく、Chrome・UWP・ElectronではWM_IME_CONTROLによるブリッジも 機能しません。広く使われているこれらのツールでも同じ壁は越えられておらず、awase固有の 実装不足ではなくWindowsのアーキテクチャそのものに起因する制約だと、ADR-029は結論づけて います。クロスプロセスIME検出には完全な解決策が存在しません。

調査は、他のツールを外から眺めるだけでは終わりませんでした。TSFのITfCompartmentEventSink という通知の仕組みをAIに実装させて動かしたところ、GUID_COMPARTMENT_KEYBOARD_OPENCLOSEという コンパートメントがthread-manager(スレッド単位の管理主体)のスコープに閉じており、別プロセスへの 切り替えには原理的に通知が届かないと分かりました。ADR-029は、動かないと分かったこの仕組みを 削除したことも記録しています。

解決策が存在しない以上、ADR-029は検出の失敗を前提にした多層の防御を組みました。

Layer 1: Shadow 追跡(即時、キーイベントベース)
Layer 2: OS 検出(500ms ポーリング + フォーカスフック)
Layer 3: フォールバック(検出失敗が続いた場合のみ昇格)

Layer 1はawase自身がキー入力の時点で仮の値を持ち、OS側の検出結果を待たずにまず自分の 推測を先に立てます。Layer 2はOS側の検出結果を定期的に問い合わせ直し、Layer 1の推測を 実際の状態と繰り返し照合します。両者が一定回数(ime_detect_miss_count >= 3)以上 食い違ったときだけ、Layer 3としてawase側の値をOSへ書き戻すime_force_on_guardが働きます。

ただしこのガードは、awaseが恒常的にOS側より信頼できる主(あるじ)になるという設計ではありません。 挙動が未確認のアプリを初めて検出しようとするブートストラップの期間と、内部状態を強制的に 立て直すpanic_reset()の直後という、二つの限られた場面だけに働く一時的な猶予です。フォーカスが 変わるたびにガードはリセットされます。「わからない」を無期限に居座らせないための、 期限付きの例外だったといえます。

三層の防御は、検出が失敗したときにawaseがどう振る舞うかという運用上の問題には答えましたが、 値そのものをどう表現するかという問題とは別です。ONかOFFかというbool一つに三つの状況を 押し込める限り、どれだけ層を重ねても状態を取り違える余地は残り続けます。層で運用を守る だけでなく、値そのものの持たせ方を変える必要がありました。

TSF(Text Services Framework)は、IMM32を置き換える目的で後から加わったAPIです。 同じ時期、awaseはIMM32・TSF・GJIのI/Oカウンタ・フォーカス変化の観測という複数のprobeを 並行して走らせており、それぞれが食い違う値を返すことも珍しくありませんでした。「どの観測を 今の判断として使うべきか」という問いは、probeを呼び出す側ごとに個別に答えられており、 集約する仕組みはまだありませんでした。先に決めるべきは集約する場所ではなく、一つひとつの 値をどんな形で持つべきかということでした。

「わからない」を、値として型に持たせる

ADR-044が最終的に採用した設計は、Option<bool>とセンチネル値の組を、専用の列挙型に 置き換えることでした。一つの整数が三つの意味を兼務していた状態と、意味ごとに型を 分けた状態を並べると、何が変わったのかが分かります。

at_msという一つの整数が兼務していた三つの意味と、AppliedImeStateで分離された三つのバリアント

/// IME apply 結果の確信度。
/// `Option<(bool, u64)>` の暗黙のセンチネル値(ts=0)を型で置き換える。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AppliedImeState {
    Unknown,
    Optimistic(bool),
    Confirmed { open: bool, at_ms: u64 },
}

三つのバリアントを読み取るときに大事なのは、それぞれが「値」だけでなく「その値をどう知ったか」 という約束を表している点です。遷移を図にすると、フォーカス変更がいつでもUnknownへ 引き戻す、一方向的な流れになっていることが分かります。

AppliedImeStateの遷移: フォーカス変更はUnknownへ戻し、apply完了だけがConfirmedへ進める

Unknown――まだ何も確認していない

フォーカスが変わった直後や、awase起動直後がこれに当たります。実際のIME状態については、 まだ一度も観測が届いていません。「一度も観測していない」という積極的な事実は、Noneという 消極的な不在からは読み取れませんでした。

Optimistic(bool)――OSにはまだ確認されていない

ユーザー操作に応じてawaseが値を書き換えた直後、非同期のapply処理が完了する前の状態です。 Optimisticという名前自体が、「この値は仮のものであり、まだ裏付けが取れていない」という 約束を運びます。この値を参照するコードは、名前を見た時点で「確定した事実として扱ってよいか」 を判断でき、ts == 0という条件式を思い出す必要はありません。

Confirmed { open, at_ms }――確認できた時刻ごと保持する

実際にOSへの反映が完了し、確認が取れた状態です。at_msは「確認できた時刻」であり、この状態 だけが後続の判断に安心して使ってよい値です。at_msという時刻そのものは、この章の範囲では 「いつ確認したか」を記録するだけの情報にとどまり、その時刻からどれだけ経てば古いと 見なすべきかという判定には、まだ使われていません。

三つのバリアントへの遷移も明文化され、以前は各所に散らばっていた条件分岐が、遷移が バリアントに紐づいたことでどの経路がどの状態を生み出すかを一箇所で見渡せるようになりました。

型を入れ替えた効果は、実際のコードの前後を比べるとわかります。

// Before: センチネル値の意味を追う必要がある
shadow_on == open && applied_at_ms > 0

// After: 型が意図を語る
matches!(state, AppliedImeState::Confirmed { open: s, .. } if *s == open)

「Before」の一行は、applied_at_ms > 0が「確認済みであること」を意味すると知っている 人にしか読めません。「After」の一行は、Confirmedという名前そのものが、それを読む条件を 語っています。三値論理を導入したこと自体よりも、値だけでなく、その値をどのように知ったかを 型に含めたことが、この設計の核心でした。

Rustのmatchはすべてのバリアントを網羅しないとコンパイルが通りません。将来四つ目の状態を 追加した場合も、既存のmatch式のうち見直しが漏れている箇所をコンパイラが機械的に 指摘してくれます。Option<(bool, u64)>tsの比較だけでは、この検査は働きません。

Option<bool>のままでも三つの状態を表現しようと思えば表現できたはずですが、それでは Someの中身が「仮の値」か「確認済みの値」かはコメントか呼び出し側の注意力に頼ることに なります。三つのバリアントを別の名前に分けたことで、コンパイラが「まだOptimisticの段階なのに Confirmedとして扱っていないか」を検査できるようになりました。

Physical AIへの接続

センサーの値にも、確認済みと推定の違いがある

この設計は、IME固有の工夫には見えません。ロボットが扱うセンサー値にも、同じ構造があります。 障害物までの距離や自己位置の推定値は、「検出できたか、できなかったか」という二値に丸めた 瞬間に、「かつて確認された値」と「いま確認できていない値」の違いが失われます。値そのものを 持っているかどうかと、その値を今の判断に使ってよいかどうかは、別の軸です。センサーが一度も 値を返していない状態と、直近まで値を返していたが今は途切れている状態も、二値の成功/失敗 フラグでは区別できません。

UnknownOptimisticConfirmedという区別は、この二つの軸を型として分離する、 一つの実装パターンにすぎません。センサーの値がいつ観測されたものかを型に含めておけば、 「値はあるが古いかもしれない」状態を、「値がまったくない」状態と混同せずに済みます。二値の 成功/失敗フラグでは、この二つの状態は同じ「失敗」に潰れてしまいます。

判断を下す側のコードにとって、この違いは軽視できません。「値がない」なら安全側にフォール バックする以外の選択肢はありませんが、「値はあるが未確認」なら、その値を仮の判断材料として 使いつつ、確認が取れ次第上書きするという振る舞いを選べます。二つの状態を一つに潰した設計は、 この選択肢そのものをコードから奪ってしまいます。

ただし、この型が保証する範囲には限りがあります。Confirmedは「確認できた」という事実だけを 運び、「その確認がいつまで有効か」までは答えません。確信度を型で表現できても、その確信度が 古くなる境界線は、まだこの章の設計の外にあります。

ts = 0という一つの整数から始まったこの章は、結局のところ「値の中身」と「値の由来」を 同じ場所に押し込めないという規律に行き着きました。人間の注意力に頼っていた区別が型検査の 対象に変わったことが実質的な変化であり、この規律が答えなかった、由来が確かだった値が いつまで確かでいられるかという問いは、次の観測が積み重なるほど重みを増していきます。


設計原則

原則:「わからない」は失敗として握りつぶすのではなく、明示的な値として持たせます。

適用条件: 観測の確信度が複数の段階(未観測・仮の値・確認済みなど)に分かれ、それらを区別しないと後続の判断を誤りうる場合に適用します。

実装の形: Optionやbooleanにセンチネル値を隠すのではなく、確信度ごとに列挙型のバリアントを分け、各バリアントがどの条件で生成されるかをコード上に明文化します。

限界: この型は「今何を知っているか」を表現するに留まります。「その知識がいつ古くなるか」を保証するものではありません。