第9章:エポック

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

第III部までで、awaseはWindowsやIMEが返してくる情報を型で区別し、フィルタを重ね、最後には経路そのものを断つことで守ってきました。それぞれの局面では、症状は一度収まったように見えています。しかし同じ種類の不具合は、名前を変えながら必ず戻ってきました。

第IV部は、この本の中で第9章だけからなる一部です。ここまで三部にわたって局所的に解決してきたはずの問題が、実は一つの共通する混同——一つのstateに複数の役割を同居させていたこと——の、別々の現れだったことが見えてきます。作用点、つまりawaseが実際に不可逆な操作を送り出す直前の一点に、この混同を閉じるための契約を置き直す章です。

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

「一箇所に集約すれば直る」という発想は、この本のここまでの章で、少なくとも2度、別の名前で試されています。第5章で見たBeliefStoreからshadow_modelへの改名が、その一つです。集約するたびに、症状は一度は収まったように見えました。しかし同じ種類の不具合は、形を変えて必ず戻ってきています。

3つのコミットの中身は、それぞれ役割が違います。604cf99は観測を受理する層そのものを新設し、a0e4ec7はその受理結果を型として保証したうえで読み出し側にepochフィルタを追加し、008f039はADR-077(Architecture Decision Record、設計判断とその理由を記録しておく文書)として記録を残しています。実装が短時間で済んだのは、何を作るべきかが3か月分の失敗を経てすでに明確だったからです。

本章の問いは一つです。一つのstateに、私たちは何を混同していたのか。そして、その混同をどう構造的に閉じたのか。

ime_onという一つの値が、四つの役割を兼ねていた

開発2日目、2026年3月29日のコミットには、すでにime_onという値が登場します。

55b33c9  2026-03-29  Add IME/thumb key instant promotion + hybrid buffering strategy
5b0b42a  2026-03-29  Auto IME OFF for non-browser Undetermined controls (game/gvim protection)

この二つのコミットメッセージ自体が、単純なオン・オフ以上の複雑な事情を、すでに示唆しています。

ime_onは、その名の通り「いまIMEはオンか」を表すための、単純なbool値として出発しました。しかし、実際にコード上で読み書きされる場面を数えると、この一つの値は、少なくとも四つの異なる問いに答えるために使われていました。

第一に、ユーザーが親指キーを押して「日本語入力を始めたい」と意思表示したかどうかです。これはユーザー自身の頭の中にしかない情報で、awaseはキー入力という間接的な形でしか受け取れません。

第二に、フォーカス中のアプリケーションやWindows側が「いまIMEはオンだ」とどう報告してきたかです。これは複数の経路から、しかも互いに矛盾しうる形で届きます。

第三に、その報告を受けてawase側がIME切り替えのOS呼び出しを送り、まだ完了通知を待っている途中かどうかです。送った直後は、成功したのか失敗したのか、awase自身にもまだわかりません。

第四に、いまこの瞬間にキー入力をIME側へ回してよいか、それとも直接入力へ回すべきかという、後戻りできない選択をしてよいかどうかです。一度送出したキー入力は、取り消せません。

一つのboolに、意思・報告・進行中の変更・許可という、四つの異なる性質の情報を同居させていました。それぞれは更新される頻度も、信頼できる度合いも、失効するタイミングも違います。しかし読み書きする側からは、同じ一つのフィールドにしか見えていませんでした。

四つの役割を並べて見ると、混同の輪郭がはっきりします。

一つのime_onが、書く人も失効するタイミングも違う四つの役割を同時に背負っていた

この混同は、抽象的な設計論としてではなく、具体的な分岐の増殖として姿を現します。ある呼び出しは「ユーザーの意思」を読みたいだけなのに、実際に手に取れるのは四つが混ざったime_onしかありません。呼び出し側は「いまはこの分岐を通っているはずだから、この値は意思のはずだ」という前提をコードの外に置いたまま値をそのまま使い、前提が崩れる場面――報告がまだ届いていないうちに次の意思表示が来た場合など――にだけ症状が現れました。たとえば古い観測がまだ有効だと誤って採用されれば、ユーザーが望んだ状態と実際にエンジンが動作するモードが食い違い、何も操作していないのに入力方式が急に変わったように見えます。原因を追うと、その値が「意思」なのか「報告」なのかがコードのどこにも書かれていないことに行き着きます。この一本の値は、ime_onという名前のまま、開発2日目から3か月以上にわたって存在し続けました。

四つの問いには、四つの層で答え直す必要があった

ADR-032(docs/adr/032-ime-state-reducer-4-layer-model.md)は、この混在を次のように整理しています。

  1. 責務の混在――「ユーザーが望む状態」「OSが報告した状態」「awaseが適用中の状態」「一時的な例外」が同じbelief.ime_onフィールドに優先度順で混ざっており、reducer内で各場面ごとに分岐が増え続けた
  2. observationがintentを破壊する――observer_pollは通常set_open_requestで抑制されるが、focus_probe後にset_open_requestが「消費済み」状態になると、staleなobserveがbeliefを上書きし、Engineが誤った認識で動作する

一つ目は、四つの役割が同じ場所に置かれていたという指摘、二つ目は、その同居によって実際に何が起きるかという指摘です。ここで言うreducerとは、いまの状態と新しく届いたイベントを受け取り、次の状態を一箇所で決め直す処理のことです。実際、observer_pollは通常set_open_requestによって抑制される優先度上位の設計でしたが、focus_probeの後でset_open_requestが「消費済み」という扱いになると抑制する側がいなくなり、staleなobserver_pollの値がそのままbeliefを上書きしてしまいます。優先度という一本の軸だけでは、「誰が最終的に書き込んでよいか」という権限の問題を、あらゆる場面で表現しきれませんでした。

この整理から導かれたのが、意図・観測・遷移・障壁という四つの層です。層ごとに、答える問いは異なります。誰が書き込めるか、どの程度確実か、いつ失効するかも、層ごとに異なります。

答える問い誰が書くかいつ失効するか
Intent(意図)何を実現したいかユーザー操作・awase自身の判断のみ次の意思表示があるまで有効
Observation(観測)外部世界について何が観測されたか複数のprobe・pollが並行して書く観測した瞬間から古びていく
Transition(遷移)変更処理はどこまで進んでいるかOSへの適用要求を出した経路完了通知かタイムアウトで終わる
Barrier(障壁)いま不可逆な作用を許してよいか他の三層を読んだ上で最後に一度だけ判断の都度、次の入力までしか有効でない

四層の関係を図にすると、ObservationとIntentがそれぞれ独立にTransitionとBarrierへつながり、最後にBarrierだけが作用の可否を決める形が見えます。

Intent/Observation/Transition/Barrierの4層: Barrierだけが最後に不可逆な作用の可否を判断する

実際のコードでは、優先度によって並んでいた五段階のソースが、そのままこの四層に対応します。sync_keyphysical_keyはIntentへ、set_open_requestはTransitionへ、それぞれ再分類されました。focus_probeobserver_pollはObservationへ、ctrl_bypass_holdのような一時隔離の仕組みはBarrierへ、同様に割り当てられています。

Intentを書き込めるのは、ユーザーの操作かawase自身の判断だけです。ADR-032の原則は、これを一字一句こう定めています。

UserIntentだけがdesired_openを即時に変えられる Observerはdesired_openを直接壊さない――observer/モジュールからdesired_openへの代入を直接行わない。ImeEvent::ObserverReportedをdispatchしてreducerに判断させる

Observationは「観測した」という事実だけを運びます。それをIntentへ反映してよいかどうかの判断は、必ずreducerという一箇所へ集約するということです。四層のうち、ObservationとBarrierの関係が、この本を通じて繰り返し問うてきたことの核心です。観測したことと、いま実行してよいことは、同じではありません。

四層はまた、互いの内部表現を共有しません。Observationはどのprobeがどんな経路で観測したかという出所の情報を持ちますが、Intentが必要とするのは「ユーザーが何を望んでいるか」だけであり、観測がどこから来たかを混ぜて受け取れば、また責務は元へ戻ります。四つの箱を分けるということは、値を分けるだけでなく、それぞれの箱が知ってよい情報の範囲を分けるということでもあります。

原則を文書にまとめたことと、原則が実装で守られることは、別の作業でした。ADR-032が定義された後の6月30日から7月1日にかけて、21件の関連課題が一括で処理されています。そのうちの一つは、四層に分けたはずのImeBelief.input_modeが実際にはpubのまま公開されており、reducerを経由せずに外部から直接書き換えられる状態だったという指摘でした。四層に分けるという原則は文書の上ではすでに存在していましたが、コンパイラで強制する作業は別の日にちで改めて必要になったということです。

この2日間で見つかった問題は、公開範囲だけではありませんでした。一つは循環依存で、本来は下位のモジュールが上位のモジュールを知らないまま動くべきところ、下位側のengine::decisionが上位にあるはずのplatform::EffectOriginを参照しており、依存の向きが逆転していました。もう一つはGod Object、つまりPlatformStateOutputのような一つの構造体に、複数の責務を抱え込ませすぎた状態です。最終的に、層をまたぐ禁止事項はdocs/layer-boundaries.mdへ、grep一発で違反箇所を洗い出せる形で書き出されています。

この整理では、二つの代替案が検討され、いずれも却下されています。

段階的リファクタを先延ばし→採用しなかった。循環依存は放置するほど絡まり、一度解消しないと次のリファクタのコストが指数的に増える。 God Objectを「整理」だけして分割しない→採用しなかった。責務の混在が根本原因であり、コメント整理では解決しない。

「後で分ければよい」も「コメントで整理すればよい」も、責務を型として分けることの代わりにはなりませんでした。原則を書くだけでなく、原則の違反を機械的に検出可能にするところまで踏み込んで、ようやく四層は維持され始めました。

この四層構造は、awase固有の発明ではありません。航空機のフライバイワイヤ(操縦桿の動きを電気信号でコンピュータに伝え、コンピュータが実際の舵面を動かす方式)には、よく似た四層がすでにあります。パイロットが操縦桿を倒す動作はIntent、速度計や姿勢センサーが返す値はObservation、舵面が実際に動いている途中の状態はTransitionにあたり、意図とセンサーの両方を読んだ上で失速につながる操作を最後に拒否する飛行制御コンピュータの保護則が、Barrierの役目です。

五つの決着を、同じ混同の解消として読み直す

ここまでの決着を、この四層に当てはめ直すと、それぞれが実は同じ種類の問題――四層のどれか一つを、専用の仕組みなしに済ませようとしていた問題――だったことが見えてきます。

当時の決着四層での位置づけ
第3章固定待機からLiteralDetectFsmへTransitionの進行度を、時間の長さで代用していた
第4章センチネル値からUnknown/Optimistic/ConfirmedへObservationの不確実性を、型として表現し直した
第5章BeliefStoreを経てshadow_modelへ集約Observation・Intent・作用履歴を一つの構造へ集めすぎた
第7章フィルタ強化から経路遮断へBarrierの役割を、後段のイベント判定で代用していた
第8章増殖したFSM群からStepCoroへTransitionの表現形式を選び直しただけだった

五つの章は、別々の問題を解いていたのではありません。同じ四層構造のどこか一つを、専用の仕組みなしに済ませようとして、繰り返しつまずいていました。事件の数だけ問題があったのではなく、一つの構造の中の四つの穴を、順番に踏み抜いていただけだったということです。実際、一つの層をどれだけ丁寧に作り込んでも別の層の混同は残ることは、第4章の型導入後も第5章・第7章の症状が解決しなかった経過が示しています。

この「一箇所に集約すれば直る」という発想そのものは、IME belief以外の場所でも独立に2回、同じ形で現れています。GJIの前置キー待機キューでは、複数のprobeがそれぞれ個別に保留中のキーを抱えており、probeを切り替えるたびに保留していたキーごと破棄される不具合がありました。解決は、単一の所有者(TsfWarmupCoordinator)へ保留状態を集約することでした。そして本章の主題である観測受理そのものも、7種のprobeがそれぞれ同じ判定ロジックを3箇所にコピーしていたという、同型の問題を抱えていました。一箇所に集約すれば直るはずだという期待が、集約点自体の中でまた責務を混ぜてしまう。単一プロセスの中に複数の非同期な情報源が同居する設計では、この繰り返しは偶然ではなく、構造的に起こりやすいと言えます。

しかし第5章が示した通り、四層に分けたという事実そのものは、7月4日の再発を防げませんでした。四層のどれもが、まだ「この観測はどの世界についての観測か」を表現できていなかったからです。

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

7月4日のALT+TABバグに対して、最初に打たれた対策は時間による猶予でした。直前までshadow_onがオン相当で、かつ観測が届いてから200ミリ秒未満なら、その観測を疑わしいとみなします。この対策は、報告された症状を実際に抑え込みました。

当時、awaseがIME状態を把握するために動員していたプローブは、7種類に増えていました。TSF(Text Services Framework)とIMM32(Input Method Manager)という2つのAPI系統をまたぎ、同期・非同期・イベント駆動という異なる性質を持つプローブを並行して使う設計です(表中の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なし

「信頼度」の列は、そのプローブが返す値をどこまで単独で信じてよいかを表しています。ImmCrossProbeが抱えていた問題は、信頼度の高さと観測が属する文脈の古さが別々の軸であるにもかかわらず、区別されずに扱われていたことです。実際、7種類のうち3種類がほぼ同じ条件式をそれぞれの場所にコピーしており、200ミリ秒という数字自体に根拠がなくCPU負荷が高い環境では簡単に超えてしまいます。同じ条件式が3箇所にある以上どれか1箇所を直しても残り2箇所は直らず、直ったのは症状であって、それを生んでいた構造ではありませんでした。必要だったのは、時間の長さを測ることではなく、観測がどの文脈に属していたかを直接識別することでした。

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

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

そこで導入されたのが、FocusEpochという世代番号です。FocusStore::focus_epochというただのu64で、フォーカス先のプロセスが変わるたびにwrapping_add(1)で1つ増えます。

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

記録するタイミングと、比較するタイミングは、意図的に分けられています。記録するのは観測を始めた瞬間、比較するのは観測が終わり結果を使おうとする瞬間です。この間にフォーカスが変わっていなければ、2つのepochは一致します。

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

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を返します。_private: ()というフィールドが、admit()を経由しない限りこの型の値を組み立てられないようにしています。

admit()は、二つの数値を比べるだけの、副作用のない関数です。3か月の間に2度、同じ問題を別の名前で解決しようとして失敗してきたのは、この判定がコード中に散らばっていたことも一因でした。判定を一箇所の小さな関数へ閉じ込めたことで、以後プローブが増えても、admissionのロジックを個別に書き足す必要がなくなりました。

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

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

拒否された観測は、ただ捨てられるだけではありません。REJECTED_EPOCH_MISMATCHというカウンタが1つ増えます。判定を1回の分岐で終わらせず、その結果を数え続けるところまで含めて、設計の一部になっています。

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

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

AcceptedObservationという型の価値を、正確に言い当てる必要があります。この型が実現したのは、動的な検査を静的な検査に置き換えたことではなく、動的なepoch照合を一度通過したという事実を後段まで運べる値にしたことです。型によって鮮度が永久に保証される、という意味ではありません。型が保証できるのは、過去の一点についての事実だけです。「この値は、確かに一度admit()という関所を通った」という、構築時点の証明にとどまります。そこから先、その値が実際に使われるまでの間に世界がどう変わったかについて、型は何も語りません。

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

観測が生まれてから使われるまでを一続きの流れとして並べると、観測の開始・観測の完了・観測の受理・信念への反映・効果の発行・効果の実行という六つの段階に分かれます。epochを照合できるのは、このうち「観測の完了」から「観測の受理」へ進む、ごく短い一区間だけです。区間の外――特に「効果の発行」から「効果の実行」までの間――に同じ照合があるかは、この節ではまだ問いません。

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

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

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

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

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

この照合はすべてのプローブに及んでいるわけではありません。イベント駆動のGJI・TSF Observerは、いつ届くかをこちらが選べないためepoch照合になじまず、代わりに3000ミリ秒の鮮度ウィンドウで守られています。同期ポーリングのObserverPollも対象外ですが、こちらは500ミリ秒ごとに状態を取りに行くため、フォーカスが変わった後も古い値が返り続けるという不一致自体がそもそも起こらないからです。epoch導入によっても閉じ切っていないこの隙間が、どこまで安全かは第16章で扱います。

呼び出す側のeffective_open()は、呼ばれるたびに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)
}

一度受理したら終わり、という設計ではありません。使うたびに、いまの世界と照合し直す設計です。

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

この「巡目の番号を振り、古い巡目からの報告は無視する」という工夫自体は、awaseが発明したものではありません。Googleが2006年に発表した分散ロックサービスChubbyや、Kubernetesが持つresourceVersionという仕組み、複数の候補から一人のリーダーを選ぶRaftというアルゴリズムの「任期」番号は、いずれも同じ骨格を持っています。番号を振って古いものを捨てるという着想そのものに、awase固有の新しさはありません。

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

あえて工夫と呼べる点があるとすれば、番号を比べること自体ではなく、「番号が一致することを確認しない限り、そもそも値を受け取れない」という制約の掛け方です。駅の改札を思い浮かべてください。古い定期券でも一応通れてしまい、あとで駅員が記録を見て無効だったと気づくのと、そもそも改札のバーが開かず物理的に通れないのとでは、守り方の強さが違います。型は、不正な構築を防ぐ関所にすぎません。awaseの設計は後者で、admit()という関所を通さずにAcceptedObservationという値を作る経路は、通常のコードには残していません。

Physical AIへの接続

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

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

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

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

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

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

この考え方を一般化すると、次のような一続きの流れとして描けます。観測(RawObservation)→受理(epoch付き)→信念(WorldSnapshot)→提案された効果(ProposedEffect)→実行直前の再検証→ハードウェアコマンド、という順序をたどります。

awaseがこれまで実装してきたのは、この流れの前半です。観測が受理され、信念(WorldSnapshot相当のshadow_model)へ反映されるところまでは、epochという離散的な世代照合で守られています。信念から実際のハードウェアコマンド(awaseで言えばOSへのIME切替要求)へ至る経路の「実行直前の再検証」を、この章ではまだ何も述べていません。

著者が別に設計しているUnitree Go2 Runtimeというロボット向けランタイムにも、よく似た観測の型がすでに存在します。Observed<T>という、awaseより精緻に見える観測抽象化です。しかし、その先で一つだけ欠けている場所が見つかっています。詳細は付録にまとめてあります。その欠け方は、ここまで見てきた話と対をなしています。

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

観測の経路には、書き込み時の型保証、ストア時の来歴記録、読み出し時の再照合という、3つの防御が重なっています。効果の経路については、まだ何も述べていません。

3か月かけて仮説を立て直し、82分でそれを型にした結果が、ここまでの仕組みです。古い観測は、拒否できるようになりました。しかし、古い観測からすでに作られたコマンドは、まだキューに残り得ます。エポックが閉じたのは、観測が意図を汚染するという入口の混同でした。実行した結果が意図をどう汚染しうるかという出口の混同は、まだ手つかずのまま残っています。

一つのstateに四つの役割を混同していたという問題は、型を分け、書き込める経路を絞り、世代を照合するという三段構えによって、構造的に閉じられました。しかし観測を受理する層を閉じたことは、実行した結果がどう扱われるかという、まったく別の層をまだ何も保証していないということでもあります。これから続く章が向き合うのは、その別の層で起きる出来事です。

この4層構造が、開/閉という一つの軸に限っても万能ではなかったことは、後になって分かりました。2026年8月、Win+Ctrl+→でTsfNativeアプリへフォーカスを移した直後、最弱の観測(conv推論、confidence中程度)だけを根拠にIMEが意図せずONになるバグが見つかりました(ADR-087)。同じconv観測値でも、実IMEがONの場合とOFFの場合がある、という対称的な2つの不具合は、信頼度の調整だけでは両立して解けません。ここから見えてきたのは、「engineの挙動決定に使うbelief」と「外部への書き込みを許可する根拠(warrant)」は別の型に分けるべきだ、という区別でした。beliefの誤りは訂正できますが、書き込みという不可逆な行為を許す根拠には、beliefより強い証拠を要求してよいはずです。時間軸・空間軸・トリガー軸に続く第四の軸として、この根拠軸が加わったことになります。純粋ロジックの部分は4608通りの網羅比較テストで固定されていますが、実配線と実機ソークはまだ済んでいません。

一つの確信度を判断と許可の両方に使い回した場合と、型を分けた場合の違いを並べると、次のようになります。

beliefとwarrantは、同じ強さの証拠であるべきではない

ここから引き出せるのは、AcceptedObservationが示した原則とは別の教訓です。確信度を一つの数値・一つの型で表し、その数値を「内部の判断に使ってよいか」と「外部への不可逆な作用を許可してよいか」の両方に使い回すと、この二つの基準が食い違う瞬間に必ずどちらかの方向へ壊れます。判断に使うbeliefと、不可逆な作用を許可するwarrantは、最初から別の型にしておくべきでした。

もう一つ、この4層モデルとは方向の異なる決着もありました。かな入力方式(charset)の状態については、awaseは特定の状態を予測してbelief化すること自体をやめ、config1.dbによる自動判定とベストエフォートの助言に切り替えています(ADR-091・094)。ある軸について、信念を正しく管理する型を作るのではなく、その軸の信念を持つこと自体を諦める、という選択肢です。ここで学べるのは、追跡する型を洗練させる方向だけでなく、「その軸のbeliefを持たない」という選択肢も設計の候補に入れておくべきだ、という判断基準です。外部の状態を確実に予測する手段がなく、誤って追跡したときの訂正コストの方が、追跡自体をやめるコストより高いなら、beliefを持たないことが最も安全な設計になり得ます。4層で全てを追跡することが、常に正解とは限りません。


設計原則

原則: 責務を型で分けることと、その型が指す文脈がいまも同じであることを保証することは、別の設計問題です。

適用条件: 一つの構造体やフィールドに複数の異なる時間軸・複数の書き込み主体からの情報が混在し、改名や再構成を繰り返しても同じ種類のバグが形を変えて戻ってくる場面に当てはまります。加えて、観測や処理が非同期に完了し、完了した時点で前提がすでに変わっているかもしれない場面にも適用します。

実装の形: まず、混在している役割を「誰が書くか」で分類し、役割ごとに別の型を用意します。書き込める経路を一つに絞り、その制約をprivateフィールドやアクセス制御によって、コンパイラやgrepで機械的に検出できるルールへ変換します。そのうえで、増加するだけの世代番号を用意し、観測や処理を開始する時点でいまの世代番号を記録します。値を実際に使う瞬間には、記録した世代といまの世代を必ずもう一度突き合わせます。

限界: 型を分けることは、責務が混ざらなくなることを保証するだけです。分けた型同士がどの文脈・どの世代の情報を指しているかは、型を分けただけでは何も保証しません。世代照合も、対象に含めていない経路(イベント駆動の観測など)には及ばず、観測をもとにすでに生成された作用が、送信される直前に古い文脈のまま実行されることは防げません。