第11章:エポック

全体像の中でこの章が扱う部分: 世代照合(詳細は付録「全体像」を参照)

実装は84分で終わりました。2026年7月4日、604cf99(06:58)・a0e4ec7(07:42)・008f039(08:20)という3つのコミットが、その日のうちに積み重ねられています。しかし、この84分を可能にしたのは、それ以前の3か月間、同じ問題に何度も失敗してきた記録でした。

「一箇所に集約すれば直る」という発想は、前章までにすでに2度、別の名前で試され、そのたびに同じ症状が形を変えて戻ってきています。84分という数字だけを見れば、鮮やかな解決に見えます。しかし本章が描きたいのは、その84分の前に横たわっていた3か月分の失敗の方です。

3つのコミットの中身は、それぞれ役割が違います。604cf99は観測を受理する層そのものを新設し、a0e4ec7はその受理結果を型として保証したうえで、読み出し側にepochによるフィルタを追加し、008f039はADR-077として記録を残しています。実装が短時間で済んだのは、何を作るべきかが、3か月分の失敗を経てすでに明確だったからです。逆に言えば、何を作るべきかが定まっていなかった3か月の間は、同じ問題の周りを、名前を変えながら回り続けていたことになります。

本章の問いは一つです。観測が古いことを、どう構造的に拒否するか。

フォーカスは、対話の途中で一度変わっていた

発端は、ALT+TABによるウィンドウ切り替えでした。LINE(Qt/ImmCrossを使うアプリケーション)にフォーカスがある状態から別のウィンドウへ切り替えると、awaseのエンジンがローマ字直接入力(Engine OFF)へ落ちる、という不具合が報告されています。

原因はADR-077に記録されています。Windowsは、ALT+TABでウィンドウを切り替える間、候補を並べて選ばせるための専用のUI(XamlExplorerHostIslandWindowForegroundStagingといった、ユーザーには直接見えないウィンドウ)へ、ごく短い間だけフォーカスを移します。この中継地点は、ユーザーの体感としては一瞬で通り過ぎるものですが、OSの内部では確かに一つのフォーカス先として存在しています。このタイミングで起動していたImmCrossProbe(非同期のプローブ)が、経由ウィンドウを対象にIME状態を読み取り、falseを返していました。当時の実装はこのfalseを高い信頼度で即座に採用し、結果としてエンジンが停止していました。

これは、センサーが嘘をついた話ではありません。センサーは正直に、しかしもう関係のない場所を見て、その通りに報告していました。観測は正しかったのです。ただしその観測は、もう関係のない文脈についての観測でした。

この不具合の厄介さは、再現条件にあります。ALT+TABの操作自体は毎回同じでも、非同期プローブの完了がタスクスイッチャーの表示と重なるかどうかは、そのときのCPU負荷やスケジューリングに左右されます。重ならなければ何も起きません。重なった瞬間にだけ、エンジンが停止します。単一のプロセスの中に、タイミング次第で結果が変わる、小さな分散システムが隠れていたことになります。

この時系列を図にすると、次のようになります。

FocusEpochがNからN+1へ変わった後にプローブが完了すると、旧設計は採用しEngine OFFへ、新設計は拒否する

この図から読み取れることは一つです。プローブが返したfalse自体は、そのプローブが見た対象について嘘をついていません。壊れていたのは、値ではなく、値がどの文脈に属していたかを誰も記録していなかったことでした。

旧設計と新設計の違いは、判断に使う材料の違いに現れています。旧設計が見ていたのは、観測がどれだけ新しいか、という1つの軸だけでした。新設計が見ているのは、観測がいつ生まれたかに加えて、観測がどの世代のフォーカスの下で生まれたか、という2つ目の軸です。この2つ目の軸を持たない限り、どれだけ精密に時間を測っても、この不具合は解けませんでした。

ここで分かるのは、経過時間だけでは足りないということです。仮にこのプローブが完了までにかかった時間がごく短くても、対象がすでに別のウィンドウへ切り替わっていれば、その観測は無効です。必要なのは「何ミリ秒前の観測か」だけではなく、「どの文脈で得られた観測か」でした。

同じ「新しさ」という言葉が、二つの違う量を指していたことになります。一つは、観測が完了してから経過した時間です。もう一つは、観測が開始された時点のフォーカスと、いまのフォーカスが同じ世界を指しているかどうかです。前者は時計があれば測れます。後者は、フォーカスが変わったという出来事そのものを数えない限り、測る手段がありません。

先に試した猶予時間は、三箇所にコピーされた

この時点で、awaseがIME状態を把握するために動員していたプローブは、7種類に増えていました。TSFとIMM32という2つのAPI系統をまたぎ、同期・非同期・イベント駆動という異なる性質を持つプローブを並行して使う設計です。単独のプローブでは、すべての状況を十分な精度で捉えられなかったため、複数の情報源を組み合わせる必要がありました。組み合わせが増えるほど、どの観測をどこまで信じるかという判断も、複雑になっていきます。

最初に採られた対策は、時間による猶予でした。shadow_on && probe_age_ms < 200msという条件を満たす間だけ、ImmCrossProbeが返すfalseを抑制します。手元にある情報が「観測が完了した時刻」と「直前のIME状態」だけであれば、時間の近さを信頼度の代わりに使うのは、最初の一手としては自然な選択でした。実際、この対策は報告された症状を実際に抑え込んでいます。

当時、awaseには7種類のプローブがあり、それぞれ次のような抑制条件を持っていました(表中のGJIは Google Japanese Input、Google 日本語入力の略で、競合する別会社の日本語入力エンジンを指します)。

プローブ種別信頼度抑制条件(当時)
ImmCrossProbe非同期Highshadow_on && probe_age < 200ms
FocusProbe同期(first-key)Low同上(コピー)
ObserverPoll同期(500ms周期)Medium同上(コピー)
GJIイベント駆動Mediumlast_ioタイムスタンプ
TSF Observerイベント駆動Medium観測のみ、desired不変
HwndCache同期Lowなし
ImmGetOpenStatus同期Highなし

「信頼度」の列は、そのプローブが返す値を、他の情報と照らし合わせずにどこまで信じてよいかを表しています。Highは通常もっとも信用され、awaseの判断を単独で左右する力を持っていました。ImmCrossProbeが抱えていた問題は、信頼度そのものの設定が誤っていたのではなく、信頼度の高さと、観測が属する文脈の古さが、別々の軸であるにもかかわらず、区別されずに扱われていたことです。

この表が示すのは、症状は抑えられても、判断の根拠が3箇所に複製されていたという事実です。ADR-077自身も、この対策には構造的な欠陥が残ると認めています。欠陥は、大きく3つに分けられます。

第一に、200msという数字自体に根拠がありませんでした。ALT+TABの操作からタスクスイッチャーの表示までにかかる時間は、そのときのCPU負荷やプロセス数によって伸び縮みします。負荷が高い環境では、200msを超えてから古い観測が届くことも起こり得ます。第二に、同じ条件式がImmCrossProbeFocusProbeObserverPollの3箇所に、ほぼそのままコピーされていました。どれか1箇所を直しても、残り2箇所は直りません。第三に、「この観測は信用できるか」という判断そのものが、特定の1箇所に集約されておらず、コードベース全体に散らばっていました。

一見、不具合は直っています。しかし直ったのは症状であって、症状を生んでいた構造ではありませんでした。時間を根拠にする限り、CPU負荷やスケジューリングの揺らぎが変われば、同じ種類の不具合はいつでも再発しえます。必要だったのは、時間の長さを測ることではなく、観測がどの文脈に属していたかを直接識別することでした。

フォーカスの世代を数字にする

ここでいったん、「epoch」という言葉そのものを確認しておきます。epoch(エポック)は、この章では「区切りが変わるたびに1ずつ増える通し番号」という意味で使います。フォーカス(いまキー入力を受け取っているウィンドウ)が切り替わるたびに1つずつ増えていく番号だと考えれば十分です。「いまが何回目の区切りにいるか」を数えるための値です。時計のように時間の長さを測るのではなく、区切りが起きた回数だけを数えます。

そこで導入されたのが、この考え方を体現したFocusEpochという世代番号です。FocusStore::focus_epochというただのu64で、on_focus_process_changedが呼ばれるたびにwrapping_add(1)で1つ増えます。増える条件は一つだけです。フォーカス先のプロセスが変わったときだけ、この番号は進みます。

非同期プローブ(ImmCrossProbeFocusProbe)は、起動(spawn)する瞬間に、そのときのFocusEpochを1つだけ記録します。これがImmLikeTicketです。プローブが完了して結果を届けようとするとき、admit()がこのチケットに刻まれたepochと、いまの最新epochを突き合わせます。

記録するタイミングと、比較するタイミングは、意図的に分けられています。記録するのは、観測を始めた瞬間です。比較するのは、観測が終わり、その結果を実際に使おうとする瞬間です。この間にフォーカスが変わっていなければ、2つのepochは一致します。変わっていれば、一致しません。一致・不一致という単純な判定だけで、「この観測はいまの文脈に属しているか」という問いに答えられることになります。

ImmLikeTicketという名前が示す通り、この仕組みはImmCrossProbe専用ではありません。「起動時のフォーカスを覚えておき、完了時に照合する」という性質を持つプローブであれば、FocusProbeのように種類が違っても、同じチケットを共有できます。7種類あったプローブのうち、この性質を持つものだけが対象になり、GJIやTSF Observerのようにイベント駆動で届くプローブは、次の節で扱う別の理由により対象から外れています。

ImmLikeTicketはspawn時のフォーカス世代(focus_epoch)を1つだけ保持する軽い値です。 一方AcceptedObservationは非公開フィールドを持ち、この型の値を外部から直接組み立てることは できません。手に入れる方法はadmit()を通ることだけです。

pub fn admit(self, current_epoch: FocusEpoch) -> Admission {
    if current_epoch != self.focus_epoch {
        REJECTED_EPOCH_MISMATCH.fetch_add(1, Ordering::Relaxed);
        return Admission::Reject(RejectReason::FocusEpochChanged { .. });
    }
    Admission::Accept(AcceptedObservation { focus_epoch: current_epoch, _private: () })
}

spawn時のepochと現在のepochが一致しなければReject、一致すれば非公開フィールド経由で AcceptedObservationを組み立ててAcceptを返す。この2択だけが、外部からAcceptedObservation を得る唯一の経路です。

拒否された観測は、ただ捨てられるだけではありません。REJECTED_EPOCH_MISMATCHというカウンタが1つ増えます。これは、この不具合が実際にどれくらいの頻度で起きていたか、直してから再発していないかを、あとから確認できるようにするためのものです。判定を1回の分岐で終わらせず、その結果を数え続けるところまで含めて、設計の一部になっています。

admit()は、引数として渡された二つの数値を比べるだけの、副作用のない関数です。実際に フォーカスを切り替えたり、非同期プローブを本当に走らせたりしなくても、focus_epochcurrent_epochに好きな値を入れて呼び出すだけで、一致する場合・しない場合の両方を テストコードの中で確認できます。3か月の間に2度、同じ問題を別の名前で解決しようとして 失敗してきたのは、この判定がコード中に散らばっていて、実機を使った再現でしか確かめられ なかったことも一因でした。一つの純粋な関数にまとまった後は、この判定そのものを実機なしで 検証できます。

この型が実装された604cf99の変更行数は、わずか+111行、3ファイルでした。3箇所にコピーされていた時間ベースの推測を、小さな型を1つ足すことで置き換えられたことになります。行数の小ささは、設計が簡単だったことを意味しません。何を1つの値として運ぶべきかが定まっていれば、実装そのものは小さくて済む、ということだけを示しています。

受理は証明にすぎない、鮮度は使うたびに確かめる

AcceptedObservationという型の価値を、正確に言い当てる必要があります。この型が実現したのは、動的な検査を静的な検査に置き換えたことではありません。動的なepoch照合を一度通過したという事実を、後段まで運べる値にしたことです。型によって鮮度が永久に保証される、という意味ではありません。

この型が持つ_private: ()というフィールドは、admit()を経由しない限り、この型の値を外部から直接組み立てられないようにしています。将来プローブを追加する実装者は、書き込み関数のシグネチャにAcceptedObservationが要求されているのを見て、「admissionを通らなければならない」と自然に気づく構造です。しかしこれは、構築された瞬間に世界がどうだったかの証明であって、その1マイクロ秒後にフォーカスが変わっていないという保証ではありません。

型が保証できるのは、過去の一点についての事実だけです。「この値は、確かに一度admit()という関所を通った」という、構築時点の証明にとどまります。そこから先、その値が実際に使われるまでの間に世界がどう変わったかについて、型は何も語りません。型検査はコンパイル時に一度だけ行われますが、フォーカスはプログラムが動いている間、いつでも変わり得るからです。

もし本当に動的検査を静的検査へ置き換えられていたなら、コンパイラは「このAcceptedObservationは、使われる時点でも新鮮である」と保証できたはずです。しかし、コンパイラは実行時にフォーカスが変わるかどうかを知りません。知り得ないものを、型だけで保証することはできません。AcceptedObservationが本当に置き換えたのは、「新鮮であるという保証」ではなく、「過去に一度、正しい手順で確認された」という、来歴の記録の仕方です。

実際の設計は、次の3層で完成しています。

書き込み時: ImmLikeTicket::admit()AcceptedObservation(型による証明) ストア時: ImeObservation.focus_epochに記録(来歴の記録) 読み出し時: derive_open()がepochフィルタを再適用(実行時の再照合)

3層はそれぞれ、別の失敗を防ぐために存在します。書き込み時の型がなければ、admissionを経由しない値がどこかから紛れ込む経路を防げません。ストア時に世代を記録しなければ、observationがどの文脈で生まれたのかを、あとから参照する手段がなくなります。読み出し時の再照合がなければ、受理された時点では正しかった判断が、そのまま古くなった後も使われ続けます。1つでも欠けると、残り2つだけでは鮮度を守り切れません。

鮮度を最終的に守っているのは、型ではなく3層目です。derive_open()(observation_store.rs)は、observationを読み出すたびに、記録済みのepochといまのepochを再び比較します。

let is_epoch_ok = |o: &ImeObservation| match o.source {
    ObservationSource::ImmCrossProbe | ObservationSource::FocusProbe => {
        o.focus_epoch == current_epoch
    }
    _ => true, // GJI/ObserverPoll/TSFはイベント駆動のため対象外
};

matchの最後の腕が示す通り、この照合はすべてのプローブに及んでいるわけではありません。イベント駆動のGJI・ObserverPoll・TSF Observerはepoch照合の対象外で、FRESHという3000msの鮮度ウィンドウだけで守られています。3000ms以内にフォーカスが変わり、たまたま新しいフォーカス先の状態と一致してしまえば、この機構をすり抜ける可能性は理論上残ります。イベント駆動の観測は、いつ届くかをこちらが選べないため、spawn時にepochを刻むという仕組みそのものが馴染みません。ここは、epoch導入によっても閉じ切っていない隙間として、意図的に残されています。

呼び出す側のeffective_open()(ime_model.rs)は、呼ばれるたびにInstant::now()で新しく評価されます。

pub fn effective_open(&self) -> bool {
    let base = if self.has_user_explicit_intent() {
        self.desired_open
    } else {
        self.observations.derive_open(Instant::now()).unwrap_or(self.desired_open)
    };
    self.force_guards.effective_open(base)
}

一度受理したら終わり、という設計ではありません。使うたびに、いまの世界と照合し直す設計です。型は、不正な構築を防ぐ関所にすぎず、鮮度そのものの証明書ではありません。鮮度を保っているのは、使う直前にもう一度確かめるという、地味な実行時の一手間です。

effective_open()は、awaseの中でIMEが開いているかどうかを尋ねられるたびに呼ばれます。呼ばれる回数を減らすために結果をキャッシュする、という選択肢もあり得ました。しかし、キャッシュした瞬間から、その値は少しずつ古くなっていきます。ここでは逆に、キャッシュを持たず、尋ねられるたびにderive_open()まで遡って計算し直す設計が選ばれています。速さよりも、答えが常にいまの文脈に対するものであることを優先した結果です。

似たような工夫は、日常のアプリにも隠れています。フードデリバリーの注文を思い浮かべてください。アプリは注文をドライバーAに割り当てます。ところがドライバーAの電波が悪く応答がないため、アプリは注文をドライバーBに割り当て直し、Bが実際に商品を届けます。しばらくして電波が戻ったドライバーAのスマホが、自分がまだ担当だと思い込んだまま「配達完了」を報告してきたら、記録はどうなるでしょうか。これを防ぐ仕組みは単純です。注文を割り当てるたびに「今回は何巡目の割り当てか」という番号を振り、アプリは最新の巡目からの報告だけを受け付けます。ドライバーAの報告は古い巡目のものなので、後から届いても無視されます。

この「巡目の番号を振り、古い巡目からの報告は無視する」という工夫自体は、awaseが発明したものではありません。Googleが2006年に発表した、複数のサーバーの間で「いま誰が書き込んでよいか」を管理する分散ロックサービスChubbyや、Kubernetesが持つresourceVersionという仕組み、複数の候補から一人のリーダーを選ぶRaftというアルゴリズムの「任期」番号は、いずれも同じ骨格を持っています。オンライン対戦ゲームの通信でも、ネットワークの遅れで前後してしまう操作を、フレーム番号のような通し番号で並べ直し、古い番号の操作は捨てる、という仕組みが広く使われています。番号を振って古いものを捨てるという着想そのものに、awase固有の新しさはありません。

ただし一つだけ、まぎらわしい「似て非なる」話に触れておきます。Rustというプログラミング言語のエコシステムにはcrossbeam-epochという、名前がよく似た道具があります。しかしこちらが解いているのは、複数の処理が同時に使っているかもしれないメモリを、いつ安全に片付けてよいかという、まったく別の問題です。同じ「epoch」という言葉が、たまたま違う二つの問題に使われているだけで、FocusEpochが解決した課題とは関係がありません。

では、awaseのFocusEpochAcceptedObservationに、あえて工夫と呼べる点があるとすれば何でしょうか。それは番号を比べること自体ではなく、「番号が一致することを確認しない限り、そもそも値を受け取れない」という制約の掛け方です。多くの仕組みは、番号が古いことに後から気づけるようにはなっていても、古い番号のまま先に進むこと自体は止めません。改札を思い浮かべてください。古い定期券でも一応通れてしまい、あとで駅員が記録を見て「これは無効でした」と気づくのと、そもそも改札のバーが開かず物理的に通れないのとでは、守り方の強さが違います。awaseの設計は後者で、admit()という関所を通らない限り、AcceptedObservationという値そのものが、コードのどこにも存在できません。この「そもそも作れなくする」という徹底の仕方は、狭い工夫ではありますが、実質のあるものです。

一方FocusEpochは、複数の主体を排除するための仕組みではありません。単一のプロセスの中で、いまの自分が少し前の自分と同じ文脈にいるかどうかを識別するために使われています。相手は別のプロセスではなく、少し前の自分自身です。フードデリバリーの例で言えば、ドライバーが複数いて誰が正しいかを決める話ではなく、同じドライバーのアプリがたった一人で、「さっき受けた注文と、いま受けた注文は、同じ巡目か」を確かめているようなものです。競合する主体を1つに絞ることと、1つの主体の中で文脈の世代を区別することは、似た仕組みでありながら、解いている問題が違います。

分散システムでは、複数のノードが同時に書き込みを試み、そのうちどれを正とするかが問題になります。awaseのプロセスは1つしかなく、競合する別プロセスはいません。競合しているように見えるのは、同じプロセスの「いまの自分」と「少し前の自分が起動した非同期処理」だからです。単一プロセスの内部にも、この種の擬似的な競合は生まれ得るということが、この章が示した事実です。

Physical AIへの接続

観測にも、文脈の世代がある

この構造は、日本語入力エンジンに固有のものではありません。次の対応表は、awaseで起きたことをそのままロボティクスの語彙に置き換えたものです。

awaseロボティクス/Physical AI
Windows IMEの内部状態ロボット・対象物・人間・環境の隠れ状態
ImmCross/Focus/ObserverPollなどのプローブカメラ・LiDAR・IMU・SDK状態・ネットワークテレメトリ
FocusEpochmission epoch / route epoch / control-owner epoch
AcceptedObservationいまの制御文脈で使ってよいと認められた観測

本書のはじめにで述べた命題を、もう一度ここに置きます。

外の世界を相手にするソフトウェアでは、観測したことと、いま実行してよいことを分けなければならない。

FocusEpochAcceptedObservationは、この命題をIMEという一つの領域の中で、具体的な型として実装したものです。観測したことと、いま実行してよいことの間には、文脈が同じかどうかという確認が挟まっています。カメラが対象を正しく捉えていても、その対象がミッションの前の段階に属するものであれば、いまの判断に使ってよい観測ではありません。たとえば、経路変更の直前に取得した障害物の位置は、経路変更の直後には、もう存在しない前提に基づく情報になり得ます。センサーは嘘をついていません。ただ、もう関係のない世代についての情報を、正直に返しているだけです。IMEの世界で起きていたのと同じ問い、観測がどの文脈の世代に属するか、が形を変えて現れます。

このときはまだ気づいていませんでしたが、著者が別に設計しているGo2 Runtimeというロボット向けランタイムにも、よく似た観測の型がすでに存在していました。その先で何を見つけたかは、終章で扱います。

ここまでの話が守っているのは、observationがbeliefへ届くまでの経路だけです。beliefが確定したあと、それを実際のOS操作(Effect)としてキューに積み、送信する経路については、まだ何も述べていません。ImmCrossの非同期送信は、一度呼び出されれば最後まで実行され、送信後に取り消すことはできません。

観測の経路には、書き込み時の型保証、ストア時の来歴記録、読み出し時の再照合という、3つの防御が重なっています。効果の経路には、いまのところこれに相当する防御がありません。beliefがいったん正しく更新されても、そのbeliefをもとに生成されたコマンドが、送信されるまでの間にフォーカスが変わっていないという保証は、まだどこにもありません。

3か月かけて仮説を立て直し、84分でそれを型にした結果が、ここまでの仕組みです。古い観測は、拒否できるようになりました。しかし、古い観測からすでに作られたコマンドは、まだキューに残り得ます。


設計原則

型は、かつて検査を通ったことの証明であり、いまも正しいことの証明ではない。

適用条件: 非同期に完了する観測や処理があり、完了した時点で前提がすでに変わっているかもしれない場面に適用する。開始から完了までが1つの同期的な呼び出しの中で完結し、その間に前提が変わりようのない処理には、過剰な仕組みになる。

実装の形: 増加するだけの世代番号を1つ用意し、文脈が切り替わったときにだけそれを進める。観測や処理を開始する時点で、いまの世代番号を記録する。受理した値はプライベートフィールドを持つ型としてのみ生成できるようにし、外部からの直接構築を防ぐ。そのうえで、値を実際に使う瞬間には、記録した世代といまの世代を必ずもう一度突き合わせ、一致しなかった回数を数え続けられるようにしておく。

保証しない範囲: この構造が防ぐのは、古い文脈の観測がそのまま採用されることだけである。イベント駆動で届く観測など、世代照合の対象に含めていない経路には及ばない。また、観測をもとにすでに生成された作用が、送信される直前に古い文脈のまま実行されてしまうことは、この構造だけでは防げない。守れるのは、値が生まれた瞬間の文脈だけであり、その値が使われるまでの間も文脈が同じままだという保証は、使う側が毎回確かめない限り得られない。