第10章:意図・観測・遷移・障壁

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

第I部からここまで、私たちは常に何かを直してきました。最初の一文字の化けを直し、わからない 状態に型を与え、フィルタでは防げない偽イベントを塞ぎ、増殖したFSM群を解体しました。それぞれの 修正は、目の前の症状を確かに減らしました。

しかし、直すたびに一つの違和感が残りました。修正のたびに、beliefstateと名付けられた 一つの構造体へ、新しい分岐が増えていきました。同じ場所を直しているはずなのに、次の修正もまた 同じ場所へ集まってきます。この繰り返し自体が、個々の症状ではなく、もっと手前にある何かを 疑うべきだという合図でした。

第IV部は、個々の修正から離れ、これまで何を一つの状態として扱ってきたのかを問い直します。 観測した状態と、いま実行してよい状態は、同じではありませんでした。この本のここまでの章は、 その二つを区別しないまま、症状ごとに局所的な対策を積み重ねてきたとも言えます。

第10章では、ime_onという一つの値が、実際には四つの異なる問いに答えていたことを示します。 第11章では、その四つのうち一つ、観測の鮮度をどう構造的に守るかという問いへ、3か月をかけて たどり着いた答えを見ます。

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)

この二つのコミットメッセージ自体が、すでに一筋縄ではいかない事情を示唆しています。 「hybrid buffering」も「Undetermined controls」も、単純なオン・オフ以上の状況を、 この一つの値の周辺で捌こうとしていたことの現れです。

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

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

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

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

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

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

この混同は、抽象的な設計論としてではなく、具体的な分岐の増殖として姿を現します。ある呼び出しは 「ユーザーの意思」を読みたいだけなのに、実際に手に取れるのは四つが混ざったime_onしかありません。 そこで呼び出し側は「いまはこの分岐を通っているはずだから、この値は意思のはずだ」という前提を コードの外に置いたまま、値をそのまま使いました。前提が正しい間は動きます。前提が崩れる場面―― たとえば報告がまだ届いていないうちに次の意思表示が来た場合――にだけ、症状が現れました。

たとえば、古い観測がまだ有効だと誤って採用されれば、ユーザーが望んだ状態と、実際にエンジンが 動作するモードが食い違います。ユーザーからは、何も操作していないのに入力方式が急に変わった ように見えます。原因を追うと、その値が「意思」なのか「報告」なのかが、コードのどこにも 書かれていないことに行き着きます。

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

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とは、いまの状態と新しく届いたイベントを受け取り、 次の状態を一箇所で決め直す処理のことです。この章を通じて、四層の情報を最終的に一つの判断へ 束ねる役目を担うのが、このreducerになります。優先度で並べただけの値は、優先度の低いはずの 観測が、高いはずの意図を上書きしてしまう場面を防げませんでした。

reducerを一箇所に集約したことには、もう一つの効果があります。reducerは「いまの状態」と 「届いたイベント」を受け取り「次の状態」を返すだけの関数であり、実際のIME・GJI・Windowsを 動かさなくても、四層の値を手で組み立てて渡すだけでテストできます。これから見る5回の決着は、 いずれも実機で使っているうちに発覚したバグでした。reducerが一箇所にまとまった後は、同じ 壊れ方をテストコードとして固定し、実機なしで再発を検知できるようになっています。

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

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

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

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

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

Intentの列とObservationの列を並べて見ると、両者は書く主体からして違います。Intentを 書き込めるのは、ユーザーの操作かawase自身の判断だけです。ADR-032の原則は、これを一字一句 こう定めています。

UserIntentだけがdesired_openを即時に変えられる

Observationは外部からの報告で、書き込む主体は複数(focus_probe、observer_poll等)存在します。 しかし、Observationは決してIntentを直接書き換えてはいけません。同じ原則の続きがこれを定めます。

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

Observationは「観測した」という事実だけを運びます。それをIntentへ反映してよいかどうかの判断は、 必ずreducerという一箇所へ集約するということです。

Transitionは、OSへの適用要求を送ってから完了通知を受け取るまでの、進行中の状態を表します。 IntentともObservationとも異なり、時間とともに自然に終わる(成功するか、タイムアウトするか) という性質を持ちます。Transitionが終わる前に新しいIntentが来た場合、どちらを優先するかは Transition層だけでは決められません。この調停こそがreducerの役目であり、Transitionという 箱を単独で作っただけでは、調停の問題は解決しないということも、あわせて確認しておく必要が あります。

Barrierは、四層の中でもっとも直接的に作用と結びついています。他の三層がどんな値であっても、 Barrierが「いまは許可しない」と判断すれば、実際のキー入力送出は止まります。逆に言えば、 Barrierは他の層の値を信頼した上で、最後に一度だけ判断する場所として設計されているということです。

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

四層のうち、ObservationとBarrierの関係が、この本を通じて繰り返し問うてきたことの核心です。 観測したことと、いま実行してよいことは、同じではありません。Observationがどれだけ正確でも、 Barrierが別に判断しない限り、正確な観測がそのまま作用の許可にはなりません。

他分野への転用

操縦桿とセンサーと保護則の関係に似ている

この四層構造は、awase固有の発明ではありません。航空機のフライバイワイヤ(操縦桿の動きを 機械的なケーブルではなく電気信号でコンピュータに伝え、コンピュータが実際の舵面を動かす 方式)は、よく似た四層をすでに持っています。パイロットが操縦桿を倒す動作はIntent(何を 実現したいか)にあたります。速度計・迎角センサー・姿勢センサーが返す値はObservation(外部 世界について何が観測されたか)にあたります。舵面が実際に動いている途中の状態はTransitionに あたります。そして、パイロットの意図とセンサーの観測をどちらも読んだ上で、失速や過大な 荷重につながる操作を最後に拒否する飛行制御コンピュータの保護則(フライトエンベロープ保護) は、まさにBarrierの役目です。パイロットがどれだけ強く操縦桿を引いても、保護則が「いまは 許可しない」と判断すれば、機体はその通りには動きません。

この類似は偶然ではありません。「何かを望む主体」と「外部の状態を報告する経路」と「実際に 不可逆な作用を許すかどうかの最終判断」を分けるという発想は、人間の意図を機械の動作へ翻訳 する場面であれば、対象がキーボードでも操縦桿でも同じ形になります。awaseとフライバイワイヤの 違いは、後者では判断を誤れば人命に関わるため、保護則という層が最初から明示的に設計される 点です。awaseがこの区別に至ったのは、observationがintentを壊すという具体的なバグを5回 繰り返した後でした。設計の必要性に気づく順序は違っても、行き着いた形は同じでした。

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

ここまでの決着を、この四層に当てはめ直すと、それぞれが実は同じ種類の問題――四層のどれか一つを、 専用の仕組みなしに済ませようとしていた問題――だったことが見えてきます。四層のどれか一つを 直すことと、四層すべてを一度に用意することは、別の作業です。以下では、すでに読んだ五つの章を、 この観点から棚卸しします。

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

第4章では、Windows TerminalとChromeという別々のアプリで、それぞれ最初の一文字が化けるという症状に 向き合いました。二つの事件は見た目こそ似ていましたが、発端の事件を直したあとも、待機時間を 600ミリ秒から1500ミリ秒、500ミリ秒へと調整し続ける展開が残りました。当時はこれを「正しい 待機時間を探す」問題だと考えていました。しかし四層に当てはめれば、これはTransition、つまり 変更処理がどこまで進んだかという問いに、時間の長さという代用品を当てていただけです。時間定数を どれだけ調整しても、進行中かどうかという事実そのものを表現していない限り、別の環境ではまた ずれます。最終的にLiteralDetectFsmへ移行したのは、Transitionを時間ではなく状態として表現し 直したということでした。

第5章では、センチネル値が「未観測」「古い」「失敗」を区別できないという問題に、Unknown・ Optimistic・Confirmedという型で答えました。これは四層のうちObservationに対する答えでした。 値だけでなく、その値をどのように知ったかを型に含めたという工夫は、Observation層専用の解決 だったと言えます。裏を返せば、この型がどれだけ精密になっても、IntentやTransition、Barrierの 問題には手が届きません。実際、この型が導入されたあとも、第6章・第8章の症状は解決していません。 一つの層をどれだけ丁寧に作り込んでも、別の層の混同は残るということを、章をまたいだ経過が 示しています。

第6章では、非同期の観測がbeliefを汚染するという問題に、名前を変えながら何度も向き合いました。 最終的にshadow_modelという単一のSSOTへ昇格させています。四層で振り返ると、この構造は Observationと、Intentと作用の履歴を、一つの入れ物へ集めすぎていました。SSOTにするという判断 自体は誤りではありません。ただし、SSOTの中で四つの役割が再び混ざれば、症状は形を変えて戻って きます。実際、7月4日にはALT+TAB操作でこの構造が再発しています。当時は「集約が足りなかった」 のか「集約しすぎた」のか、判然としないまま次の対策に進んでいました。四層という物差しを 先に持っていれば、shadow_modelという一つの入れ物の中に、まだ四つの役割が同居していると 気づけたはずです。

第8章では、LINEが送る偽イベントに対して、一晩のうちに段階的にフィルタを強化しました。フィルタを 一段強めるたびに、次の反例がまた見つかるという展開が続きます。最終的にたどり着いたのは、 フィルタを強くすることではなく、そもそも経路を塞ぐという判断でした。四層で見れば、フィルタが 担おうとしていたのは「いま作用を許可してよいか」というBarrierの問いです。イベントが本物か どうかを後段で判定するやり方は、Barrierの問いに、Observationの精度を上げることで答えようと していたとも言えます。経路を塞ぐという決着は、Barrierの判断をイベント判定の外側、経路そのものへ 置き直したということです。

第9章では、増殖したFSM群を棚卸しし、複数のFSMのうち大半をStepCoroへ移し替え、一部をFSMのまま 残しました。個々のFSMは名前も設計方針もばらばらでしたが、共通していたのは、いずれも 「変更処理がどこまで進んだか」というTransitionの問いに答えるための道具立てだった、という点です。 これはTransitionという同じ層の中で、表現形式を選び直した作業です。Intent、Observation、 Barrierの設計そのものには手を付けていません。

この読み直しからわかるのは、次の一点です。五つの章は、別々の問題を解いていたのではありません。 同じ四層構造のどこか一つを、専用の仕組みなしに済ませようとして、繰り返しつまずいていました。当時は それぞれ独立した事件として経験しました。しかし振り返れば、事件の数だけ問題があったのではなく、 一つの構造の中の四つの穴を、順番に踏み抜いていただけだったということです。

名前を変えた同じ日に、また名前が足りなくなった

第6章のBeliefStoreは、この混同がもっとも集中した場所でした。ここでは経緯を再演せず、実際の 改名だけを一枚の表にまとめます。

日付名前
〜2026-05-21ImeBeliefStore
2026-05-22ImeApplyLatch
2026-05-24LastAppliedImeState
2026-05-29shadow_model(IME SSOTへ昇格)

注目すべきは、5月22日の1日のうちに名前が2回変わっている点です。ImeBeliefStoreを ImeApplyLatchへ改名した同じ日に、内部のshadow_ime_onという値を、Confirmed・Intended・ Unknownという型へ分割しています。名前を変えた直後に、その名前が指す構造がまた不足だと わかったということです。

四層で見れば、この改名の繰り返しは偶然ではありません。名前を変えても、Observation・Intent・ 作用履歴という異なる責務は分解されないままだったからです。責務が型として分かれない限り、 次の名前もまた同じ理由で不足になります。

同じパターンは、awaseの中で少なくとももう一度、独立に現れています。前置キーの待機キューでは、 複数のprobeがそれぞれ個別に保留中のキーを抱えており、probeを切り替えるたびに、保留していた キーごと破棄されるという不具合がありました。「にゅうりょく」と打ったつもりが「にうりょく」に なる、というように、打鍵そのものが物理的に消える症状です。解決は、単一の所有者へ保留状態を 集約することでした。一箇所に集約すれば直るはずだという期待が、集約点自体の中でまた責務を 混ぜてしまう。単一プロセスの中に複数の非同期な情報源が同居する設計では、この繰り返しは偶然 ではなく、構造的に起こりやすいと言えます。

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

この2日間で見つかった問題は、ImeBelief.input_modeの公開範囲だけではありませんでした。 一つは循環依存でした。本来は下位のモジュールが上位のモジュールを知らないまま動くべきところ、 engine::decisionという下位側のモジュールが、上位にあるはずのplatform::EffectOriginを 参照しており、依存の向きが逆転していました。もう一つはGod Object、つまり一つの構造体に 複数の責務を抱え込ませすぎた状態です。PlatformStateは約900行、Outputは約2000行、 Runtimeは約800行にまで膨らんでおり、いずれも一つの型が複数の役割を同時に背負っていました。

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

段階的リファクタを先延ばし

→ 採用しなかった。循環依存は放置するほど絡まり、一度解消しないと次のリファクタのコストが 指数的に増える。

God Objectを「整理」だけして分割しない

→ 採用しなかった。責務の混在が根本原因であり、コメント整理では解決しない。

「後で分ければよい」も「コメントで整理すればよい」も、どちらも責務を型として分けることの 代わりにはならないと判断されたということです。最終的に、層をまたぐ禁止事項は docs/layer-boundaries.mdへ、C-1からC-6までのカテゴリとして書き出されました。「この層から この層へは直接書き込まない」という制約が、文章としてではなく、grep一発で違反箇所を洗い出せる 形で残されたということです。原則を書くだけでなく、原則の違反を機械的に検出可能にするところ まで踏み込んで、ようやく四層は維持され始めています。

四つに分けても、まだ言えないことが残った

四層に分けたことで、少なくとも「何を書く場所か」は明確になりました。Intentはユーザーの意思、 Observationは外部からの報告、Transitionは進行中の変更、Barrierは最後の許可判断です。それぞれの 役割は、もう一つの巨大な構造体の中で溶け合ってはいません。

しかし、四層に分けただけでは答えられない問いが一つ残りました。Observationには「いつ観測 されたか」という時刻はあっても、「どのフォーカス文脈についての観測か」という区別が ありませんでした。フォーカスが切り替わった直後に、切り替わる前の文脈について届いた観測は、 四層のどこに置いても、新しいのか古いのかを判定する手段を持ちません。

時刻だけでは足りない理由は単純です。観測が届くまでの時間は、CPU負荷やOSのスケジューリング 次第で伸び縮みします。「何ミリ秒前の観測か」を基準にする限り、基準そのものが状況によって ずれます。必要なのは経過時間ではなく、「その観測がどの世界について語っているか」という 文脈の一致でした。第4章がTransitionの進行度を時間で代用して失敗したのと、同じ形の失敗が、 ここでもう一度起こりうるということです。

四つに分けた箱のどれもが、まだ「この観測はどの世界についての観測か」を表現できていなかった ということです。この不足は、四層モデルの欠陥ではありません。四層モデルが果たすべき役割を きちんと果たしたからこそ、次に解くべき問いがどこにあるかが、はっきり見える形で残った ということです。

意図・観測・遷移・障壁という四つの名前を得たことで、私たちはようやく「何を混同していたか」を 言葉にできるようになりました。しかし、言葉にできたことと、それが二度と混ざらないことは、 別の話です。四層のそれぞれが、いつの世界について語っているのかを、まだ誰も保証していません。


設計原則

原則: 名前を変えるだけでは、責務は分解されない。

適用条件: 一つの構造体やフィールドに、複数の異なる時間軸・複数の書き込み主体からの情報が 混在し、修正のたびに同じ場所へ分岐が増え続ける場面に当てはまる。改名や再構成を繰り返しても 同じ種類のバグが形を変えて戻ってくるなら、責務そのものがまだ分かれていない兆候である。

実装の形: まず、混在している役割を「誰が書くか」で分類し、役割ごとに別の型を用意する。 次に、書き込める経路を一つに絞る。その制約は、コメントではなくprivateフィールドやアクセス 制御によって、コンパイラやgrepで機械的に検出できるルールへ変換する。文書化しただけの原則は、 実装がそこから外れても気づけない。

保証しない範囲: 型を分けることは、責務が混ざらなくなることを保証するだけである。分けた 型同士がどの文脈・どの世代の情報を指しているかは、型を分けただけでは何も保証しない。責務の 分解と、文脈の一致は、別の問題として残る。