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

DecisionExecutorという構造体には、次のようなフィールドがありました。
applied_snapshot: Option<(bool, u64)>
RustのOption型は、値が「存在する」場合と「存在しない」場合を型として区別して
表現するための仕組みです。ここでのboolはIMEのONかOFFかを表す真偽値、u64は0以上の
整数です。一見すると、ただの
bool値とタイムスタンプの組です。しかし当時の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する際、まず自分の側の値を先に書き換えます。
これは楽観的な事前更新で、実際にOSへ反映されたかどうかは、非同期処理が完了するまで分かりません。
確認を待ってから次のキー入力を処理すると、入力への反応が遅れて見えてしまうため、確認前の値を
先に仮置きする設計そのものは妥当でした。そのため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に限った習慣ではありません。
初期値としての0と、「まだ計算していない」ことを示す0が同じ数字である言語やAPIは
珍しくなく、境界条件の判定を誤らせる典型的な原因の一つです。applied_snapshotの場合、
その境界条件の誤りが、IMEの状態という利用者の目に見える挙動へ直接つながっていました。
「IMEはboolで十分だ」という前提が崩れた
applied_snapshotが複雑化する前、awaseの前提はもっと単純でした。IMEの状態はONかOFFかの
boolであり、awase側でキャッシュしておけば十分だという前提です。この前提はしばらく機能して
いましたが、Win11のメモ帳やChromeのようなModern UIアプリで崩れました。IMM32は、Windowsが
古くから用意している、IMEとやり取りするためのAPI群です。IMEのON/OFF状態や変換モードを、
アプリケーション側から尋ねたり指示したりするために使います。旧来のWin32アプリでは
IMM32によるクロスプロセス検出がおおむね素直に働き、awase側のキャッシュとOS側の実際の値が
食い違う場面はまれでした。前提が単純なままで済んでいたのは、対象とするアプリの側が単純
だったからにすぎません。
ADR-005によれば、これらのアプリではクロスプロセスIME検出が常にopen=0を返します。実際には
IMEがONであっても、OS側の検出APIは「OFF」としか答えません。この検出の失敗を吸収するために、
awase自身が推測を保持するshadow stateへのフォールバックが導入されました。ところがshadowを
更新する仕組みがまだ整っておらず、「IMEをOFFにしてもNICOLA変換が継続する」という一次バグが
発生しました。画面上の表示はOFFなのに、キー入力は同時打鍵の変換規則で処理され続けるという、
利用者から見れば表示と挙動が食い違う状態です。IMEの状態をON/OFFのboolでキャッシュすれば
足りるという当初の前提は、ここで実際に崩れました。キャッシュする値そのものが正しくても、
その値が「いつ、何によって確認されたか」を保持していなければ、古いキャッシュと新しい現実の
区別がつかなくなります。
この問題を掘り下げる過程で、ADR-029は既存の類似ツール(AutoHotkey/zenhan/alt-ime-ahk/
Keyhac等)も調査しています。IMM32はスレッドローカル設計であり、クロスプロセスの検出には
本質的に向きません。同じプロセス内のスレッドを想定した設計のため、別プロセスのIME状態を
覗こうとすると、信頼できない値を返しやすくなります。Chrome・UWP・Electronでは
WM_IME_CONTROLによるブリッジも機能しません。AutoHotkeyやKeyhacのような、広く使われている
ツールでも、この壁は越えられていませんでした。同じ制約に他のツールもぶつかっているという
事実は、awase固有の実装不足ではなく、Windowsのアーキテクチャそのものに起因する制約である
ことを裏付けています。ADR-029の結論は率直でした。クロスプロセスIME検出には、完全な解決策が
存在しないというものです。
解決策が存在しない以上、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を置き換える目的でWindowsに後から加わった、IMEと やり取りするための新しいAPIです。同じ時期、awaseはIMM32・TSF・GJIのI/Oカウンタ・フォーカス 変化の観測という、複数のprobeを並行して走らせていました。それぞれのprobeが食い違う値を返すことも珍しくなく、「どの観測を 今の判断として使うべきか」という問いが、probeを呼び出す側のコードごとに個別に答えられて いました。この判断をどこか一箇所へ集約する仕組みは、当時のコードにはまだありませんでした。 先に決めなければならなかったのは、集約する場所ではなく、そもそも一つひとつの値をどんな形で 持つべきかということでした。
「わからない」を、値として型に持たせる
ADR-044が最終的に採用した設計は、Option<bool>とセンチネル値の組を、専用の列挙型に
置き換えることでした。
/// 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へ
引き戻す、一方向的な流れになっていることが分かります。

Unknown――まだ何も確認していない
フォーカスが変わった直後や、awase起動直後がこれに当たります。実際のIME状態については、
まだ一度も観測が届いていません。以前のコードではNoneがこの状態を表していましたが、
「一度も観測していない」という積極的な事実は、Noneという消極的な不在からは読み取れません
でした。
Optimistic(bool)――OSにはまだ確認されていない
ユーザー操作に応じてawaseが値を書き換えた直後、非同期のapply処理が完了する前の状態です。
以前はSome((v, 0))という、ts=0という特殊値によってのみ表現されていました。Optimistic
という名前自体が、「この値は仮のものであり、まだ裏付けが取れていない」という約束を運びます。
この値を参照するコードは、名前を見た時点で「確定した事実として扱ってよいか」を判断できます。
ts == 0という条件式を思い出す必要はありません。
Confirmed { open, at_ms }――確認できた時刻ごと保持する
実際にOSへの反映が完了し、確認が取れた状態です。at_msは「確認できた時刻」であり、
以前のように「0でなければ確認済み」という間接的な判定を必要としません。この状態だけが、
後続の判断に安心して使ってよい値です。at_msという時刻そのものは、この章の範囲では
「いつ確認したか」を記録するだけの情報にとどまり、その時刻からどれだけ経てば古いと
見なすべきかという判定には、まだ使われていません。
三つのバリアントへの遷移も明文化されました。起動時とフォーカス変更直後はUnknownへ、
非同期処理の事前書き込み時はOptimisticへ、そして同期経路・非同期経路のいずれで
apply処理が完了した場合もConfirmedへ遷移します。以前は同じ条件分岐がコードの各所に
散らばっていましたが、遷移が三つのバリアントに紐づいたことで、どの経路がどの状態を
生み出すかが一箇所で見渡せるようになりました。
型を入れ替えた効果は、実際のコードの前後を比べるとわかります。
// 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>のままでも、三つの状態を表現しようと思えば表現できたはずです。しかしそれでは
NoneとSomeの区別しか型検査が守ってくれず、Someの中身が「仮の値」か「確認済みの値」
かは、結局コメントか呼び出し側の注意力に頼ることになります。三つのバリアントを別の名前に
分けたことで、コンパイラが「まだOptimisticの段階なのにConfirmedとして扱っていないか」を
検査できるようになりました。人間の注意力に頼っていた区別が、型検査の対象に変わったことが、
実質的な変化です。
この置き換えは、テストの書きやすさにも表れています。三つのバリアントへの遷移は、
フォーカス変更やapply完了といった入力を渡せば出力の状態が一意に決まる、純粋な関数として
書けます。実際のIMM32やTSFを呼び出さなくても、UnknownからOptimisticへ、
OptimisticからConfirmedへという想定済みの遷移を、テストコードの中だけで再現して
確かめられます。Windows環境を用意し、実際にIMEを切り替えて目視で確認するという手間を
減らせたのは、値の意味を型で確定させたことの副産物でした。
Physical AIへの接続
センサーの値にも、確認済みと推定の違いがある
この設計は、IME固有の工夫には見えません。ロボットが扱うセンサー値にも、同じ構造があります。 障害物までの距離や自己位置の推定値は、「検出できたか、できなかったか」という二値に丸めた 瞬間に、「かつて確認された値」と「いま確認できていない値」の違いが失われます。値そのものを 持っているかどうかと、その値を今の判断に使ってよいかどうかは、別の軸です。センサーが一度も 値を返していない状態と、直近まで値を返していたが今は途切れている状態も、二値の成功/失敗 フラグでは区別できません。
Unknown・Optimistic・Confirmedという区別は、この二つの軸を型として分離する、
一つの実装パターンにすぎません。センサーの値がいつ観測されたものかを型に含めておけば、
「値はあるが古いかもしれない」状態を、「値がまったくない」状態と混同せずに済みます。二値の
成功/失敗フラグでは、この二つの状態は同じ「失敗」に潰れてしまいます。
判断を下す側のコードにとって、この違いは軽視できません。「値がない」なら安全側にフォール バックする以外の選択肢はありませんが、「値はあるが未確認」なら、その値を仮の判断材料として 使いつつ、確認が取れ次第上書きするという振る舞いを選べます。二つの状態を一つに潰した設計は、 この選択肢そのものをコードから奪ってしまいます。
ただし、この型が保証する範囲には限りがあります。Confirmedは「確認できた」という事実だけを
運び、「その確認がいつまで有効か」までは答えません。確信度を型で表現できても、その確信度が
古くなる境界線は、まだこの章の設計の外にあります。
ts = 0という一つの整数から始まったこの章は、結局のところ「値の中身」と「値の由来」を
同じ場所に押し込めないという、一つの規律に行き着きました。この規律が答えなかった問い、
つまり由来が確かだった値がいつまで確かでいられるかという問いは、次の観測が積み重なるほど
重みを増していきます。
設計原則
原則:「わからない」は失敗として握りつぶすのではなく、明示的な値として持たせる。
適用条件: 観測の確信度が複数の段階(未観測・仮の値・確認済みなど)に分かれ、それらを区別 しないと後続の判断を誤りうる場合に適用する。
実装の形: Optionやbooleanにセンチネル値を隠すのではなく、確信度ごとに列挙型のバリアントを
分け、各バリアントがどの条件で生成されるかをコード上に明文化する。
限界: この型は「今何を知っているか」を表現するに留まる。「その知識がいつ古くなるか」を 保証するものではない。