第10章:送ったキーは、届いたのか

第IV部でawaseが閉じたのは、観測の入口でした。FocusEpochは、観測が生まれた瞬間の文脈を刻み、使われる瞬間にもう一度照合します。同じ値に見えても、刻まれた世代が違えば、もう同じものとして扱いません。

ここから第V部です。送った命令は、返ってくるとは限らない、という問いをこの部で扱います。awaseは、望ましい状態と観測を見比べ、ずれていれば実際にキーを送ったりIMM32を呼び出したりして、外の世界を書き換えます。送った命令は、いつ、どんな形で戻ってくるのでしょうか。あるいは、戻ってこないとしたら、awaseはそれをどう知ればよいのでしょうか。

送り出した結果を、いつまでの間、自分が送った当のものとして扱ってよいか。この時間をまたいだ同一視の問題は、観測の世代を区別する問題と、根は同じです。第10章と第11章は、その根を、作用という別の面から掘り下げます。第10章は送った命令の結果が観測へ返ってくるかどうかを、第11章は、まだ結果がわからない命令を、どこまでの範囲で安全に取り消せるかを扱います。

一つの関数が、この章の主役です。

state/platform_state.rscheck_drift_correctionは、「いまIMEを開いておくべきか」という望ましい状態(desired_open)と、直近の信頼できる観測(observations.most_recent_trusted())を見比べるだけの関数です。両者がずれていれば、runtime/ime_refresh.rsir_apply_drift_correctionが呼ばれ、実際にVK送信かIMM32呼び出しでIMEの状態を書き換えます。動作を並べると、次の一本道になります。

desired_open(望ましい状態) ─┐
                            ├─→ 比較 ─→ 乖離あり ─→ actuate(VK送信/IMM32呼び出し)
most_recent_trusted()(観測) ─┘

第9章まででawaseは、観測をいつのものとして信じてよいかという問いに答えを出しました。守れるようになったのは観測の側で、作用の側ではありません。この章が向き合うのはその先、書き換えた結果は観測へどう返ってくるのか、あるいは返ってこないのか、という問いです。「望ましい状態と観測を比べ、ずれていたら書き換える」というループ自体は目新しいものではなく、今回問題になったのはループの中身ではなく、それを当てはめた相手の方でした。

awaseが動くアプリは、観測できる能力がまったく違います。実際のIMM32呼び出しが素直に効くImmCross。ChromeやEdgeのように、そもそもIMM32での読み取りができないImm32Unavailable。Windows Terminalのように、実IMMクエリそのものを構造的にスキップするTsfNative。この3つのアプリ分類へ、awaseは同じcheck_drift_correction/ir_apply_drift_correctionのループを一律に適用していました。同じ薬を、体質のまったく違う3人の患者へ同じ量だけ処方したようなものです。ある患者にはよく効き、別の患者には効きすぎ、また別の患者にはまったく効きません。

3つのプロファイルが、どれだけ違う相手なのかを先に整理しておきます。

プロファイル実IMM32読み取り実IMMクエリ
ImmCross可能可能
Imm32Unavailable(Chrome/Edge)不可一部制限あり
TsfNative(Windows Terminal等)不可構造的にスキップ

この表から読み取れるのは、「読み取れるかどうか」が二択ではなく段階的に違うという点です。同じ1本のループを当てはめれば読み取れる側は機能し、読み取れない側のどこかで必ず無理が出るため、この章では効きすぎた患者とまったく効かなかった患者という、2つの正反対の壊れ方を通じて問いを立てていきます。

自分の誤読を、自分で本物にしてしまった

2026年7月8日、実機ログにこんな記録が残っています。Chrome上のGJI(Google Japanese Input、Google 日本語入力)で、ユーザーが「これでいいかな」と入力したところ、3通りの壊れ方が起きました。全部がカタカナになる「コレデイイカナ」。先頭の"k"だけがローマ字のまま残る「kおれでいいかな」。先頭の"ko"だけが残る「koれでいいかな」。ユーザーは同じ単語を、壊れるたびに何度も打ち直していました。

タイムラインを追うと、発端は入力そのものではありませんでした。同じ実機ログの05:01:26前後には、FocusChange [20408→9668→20984]という記録があります。Chromeのメインコンテンツと、GJIの候補ポップアップウィンドウとの間で、ウィンドウハンドルが3回往復していました。その28秒後、05:01:54.387[conv-mode] Hiragana/roma → ZenKata/roma (conv=0x0000001B)というログが出ています。ユーザーは何も操作していません。conv modeが誤ってカタカナへ切り替わったこと自体はログから確実ですが、初回トリガーは未解明です。GetForegroundWindow()が候補ポップアップ側を一瞬指し、誤ったconv値を拾ったという説明は調査時点の仮説にすぎず、28秒という間隔を考えると根拠は弱いままです。確かなのは、一過性の誤読が実際に一度は起きた、という一点だけです。

厄介だったのはその先です。state/conv_mode.rsConvModeMgrは、この一発の誤読を即座に確定させます。そして次に走るウォームアップ処理(tsf/warmup/cold_warmup.rspreamble())が、awase自身がいま信じているconv値を見て、VK_DBE_KATAKANAのようなキーを実際にGJIへ送信してしまいます。このウォームアップ自体は本来必要な処理ですが、それが確定していないはずの一発の誤読を勝手に確定させる経路にもなっていました。一過性の誤読が、GJIの本当の状態としてロックインされ、その後はraw conv読み取りが「本当にカタカナになったGJI」を正しく反映し続けるため、単なる誤読では済まなくなります。

awase自身の復旧・ウォームアップ機構が、誤った信念を実世界へ繰り返し書き戻す、自己参照ループになっていました。調査を担ったADR-078は、この構造をこう総括しています。「awase自身の復元機能が誤ったbeliefをcold warmupのたびにreal IMEへ再書き込みし続ける自己参照ループである」。同種の書き戻しはkey_pipeline.rsprobe_io.rsにも見つかっており、単発の欠陥ではありませんでした。

この増幅ループに対しては、ADR-078という設計判断が提案されています。撤去するだけの最小対策(ConvModeMgr::needs_conv_restore_write/mark_conv_restore_written)は、Phase 1aとして2026年7月9日に一度は実装されましたが、いまはもうコードに残っていません。後に第13章で扱う大規模な削除の巻き添えを受け、dead codeとして撤去されています。DesiredMode/EffectiveMode/ModeConstraintという全体の型設計も、いまだ提案の段階のまま一度も実装されていません。この小さな決着がなぜ消えたのかは、第13章であらためて向き合います。

補正が、二度と発火しなくなった

2026年7月22日、Chrome+GJIでタイピングしていたユーザーは、奇妙なバックスペースに気づきました。ログにはgiving up, backs=1 cleanup only (no re-send)という記録が4連続で出力され、ローマ字の再送を伴わない単発のbackspaceだけで後始末されていました。不審だったのは、その間ずっと[drift]という補正のログが一度も出ていなかったことです。

Chromeは、実際のIMM32読み取りができないImm32Unavailableに分類されるアプリです。そこでawaseは、runtime/key_pipeline.rsapply_focus_probeという関数の中で、代わりの手段を使っていました。shadow_on = self.platform_state.ime.effective_open()、つまりactuateした値、awase自身の信念そのものを、write_focus_probeという経路を通じて低信頼度(confidence=Low)の観測としてストアに書き戻します。コードのコメントには「shadowのapply値を代替観測として記録」とだけ書かれていました。

この経路は、定義上observed == desiredという状態を自作します。書き込んだ値をそのまま読み返しているだけなので、乖離が生まれる余地がありません。most_recent_trusted()は信頼度の下限を設けておらず、ほかに観測が無ければこの低信頼度の観測がそのまま採用されます。check_drift_correctionは、乖離を一度も検知できませんでした。補正は、構造的に一度も発火し得なかったのです。

「成功したかのように見える」のではありません。「成功したことにした」のです。観測を偽装すると、その後どれだけ実際の状態がずれても、二度と気づけなくなります。壊れた体温計が、壊れる直前の測定値を、いまの体温だと言い張り続けるようなものです。復旧の仕組みが、自分自身の目を潰していました。

2026年7月22日に投入された修正は、この章の主題であるActuationの型設計とは別系統でした。per-VK confirmのgive-up分岐から、send_chrome_gji_reinit_and_pollというGJI再初期化処理を直接呼ぶ応急的なパスで、この関数自体は既存の別目的の実装を呼び出しに追加しただけでした。観測ストアを経由してcheck_drift_correctionに本物の観測を与える設計も検討されましたが、400ミリ秒の待機と新しい観測ソースの追加が必要になることを理由に見送られています。

この応急パスが安全なのは、send_chrome_gji_reinit_and_pollが送るのがVK_KANJIのようなトグルキーではなく、ON/OFF専用の冪等キー(VK_IME_ON/VK_IME_OFF)だからです。もし推測が外れて実際にはすでにONだったとしても、最終的な到達状態は変わらずONのままで、トグルキーなら二重反転でOFFに固定されかねないところを、冪等な操作を選ぶことで回避していました。

ADR-080は、この非対称を隠さず明記しています。ADR-080が構造的に閉じられるのは次の節で扱う壊れ方だけで、この節で見た観測を書き込む側の欠陥は、actuation側の型変更だけでは塞がれません。片方は型で防げるが、もう片方はまだ防げていません。

675ミリ秒の間に、同じキーを16回送り続けた

2026年7月25日午前1時28分31.022秒から31.697秒まで、わずか675ミリ秒の間に、あるログが16回連続で出力されました。[drift] correction: observed=true ≠ desired=falseという乖離の検知と、VK_IME_OFFの実送信が、平均およそ45ミリ秒間隔で繰り返されていました。これはobserve tickの20ミリ秒タイマーとほぼ同期する間隔です。ユーザーは画面上で、キーが連打されたかのような挙動を観測しています。ログのduration_msは補正のたびにリセットされず、84502ミリ秒から85176ミリ秒へと単調に増え続けていました。乖離が一度も解消されないまま、補正だけが空振りし続けていたことがわかります。

舞台はWindows Terminalでした。フォアグラウンドのクラスはCASCADIA_HOSTING_WINDOW_CLASSですが、実際にフォーカスを持つのはWindows.UI.Input.InputSite.WindowClassという別のウィンドウで、force-tsf判定によってTsfNative扱いされます。原因は前節の壊れ方とは正反対の場所にありました。actuationの結果はon_ime_apply_completeが受け取りますが、generationNoneであるため観測ストアを更新するイベントがdispatchされず、「Skipping IMM query for known-broken class」というログの通り実IMMクエリ自体も行われません。観測を更新する手段はほかにどこにも残されておらず、乖離は正しく検知され続けるのに、actuationの結果が観測へ一切フィードバックされないため、収束する手段がどこにも存在しなかったのです。

さらに、desiredlast_intentが一致している場合、通常の再送閾値(DRIFT_CORRECTION_THRESHOLD_MS、400ミリ秒)ではなく即時(0ミリ秒)で再送する高速パスがありました。本来は素早い収束を助ける設計ですが、収束する見込みが無い状況では、observe tickのたびに無条件で同じVKを再送する無限ループとして働いてしまいます。送信のたびにcomposition] marked cold reason=SetOpenFalseというログも出ており、warm状態が毎回破棄され、次の文字出力のたびにVK_DBE_HIRAGANAのウォームアップが強制される計算になります。

この2つの壊れ方の対比は、ADR-080自身がそのまま言い当てています。前節は観測を書き戻して乖離を消し、収束を偽装しました。この節は逆に、actuateした結果が観測に一切返ってこず、収束を一度も記録できませんでした。同じ欠落を、正反対の側から踏んだ2つのバグでした。2つの経路を並べて見ると、この対称性がそのまま見えてきます。

同じ欠落を、書き戻しすぎる側と、返ってこない側から踏んだ

どちらの経路も、actuateした結果を観測へ正しく返すという一段が欠けています。Chromeでは自分の意図をそのまま観測として書き戻し、Windows Terminalでは観測を一切更新しません。片方は乖離を消しすぎ、片方は乖離を消せない、という正反対の結果に見えても、原因は1つでした。

暫定的な修正は、直前に同じdesiredへ補正を送ってから一定時間はスキップする、last_drift_correction_sendという手作りのクールダウン変数でした。後でわかる通り、これは単独の発明ではなく、似た目的の変数がそれまでにも独立して複数箇所に生まれていました。

同じ欠落を、安全側と活性側それぞれから踏んでいた

増幅、偽装収束、無限再送。3つの壊れ方を並べ直すと、actuateした結果を観測にどう返すか、という設計判断がどの経路にも一貫して存在していなかったという、共通する欠落が見えてきます。この欠落を裏づけるように、actuationループを自分自身で止められないという問題への場当たり的な対策が、少なくとも4箇所で互いに無関係に独立実装されていました。

変数抑えていた対象カテゴリ
Output::last_gji_reinit_msGJI再初期化のgive-up連続発火actuationの終端
Runtime::last_drift_correction_senddrift correctionの再送actuationの終端
PlatformState::focus_debounce_msフォーカス変更直後の反応観測側のちらつき
ImeKindDebounceIME種別判定のちらつき観測側のちらつき

上の2行と下の2行は別のカテゴリで、この章の主題と直接関係するのは、actuationの終端を止める前者2つだけです。散らばったbooleanフラグはFSMに吸収できるというdocs/adr/index.mdの教訓と同じく、散らばったcooldown timestampは型付きトランザクションに吸収できます。

ここまでを一段抽象化すると、次のように言えます。対象を読み戻せるかどうかが、ループの設計を決めます。決めるのは実装者の好みではなく、対象システムの観測可能性そのものです。読み戻せるならclosed-loop(確認してから次に進む)、読み戻せないならbounded blind retryと明示的な諦め、の二択しかありません。両者を同じ汎用ループでカバーしようとすると、読み戻せない側でclosed-loop前提のコードが誤動作する形で、必ずどこかにしわ寄せが出ます。

もう1つ、踏切の遮断機を思い浮かべてください。電車が来ていないのに遮断機が下りたままにならないことと、電車が通過したら必ず一定時間内に上がることとは、別々に保証すべき性質の異なる約束です。前者が崩れれば渋滞が起き、後者が崩れれば安全そのものが崩れます。これは実は、分散システムやリアルタイム制御の分野でsafety(安全性)とliveness(活性)と呼ばれる区別と同じ骨格で、awaseで言えば、型・dylint・architecture guardが守る安全性があっても、補正が必ず有限回で収束するという活性は自動的には守られません。前節までの2つの壊れ方は、同一の欠落をsafety/livenessそれぞれの側から踏んだものだった、という整理です。

この整理は、Claude Fable 5との壁打ちで得られたフレーミングと、実装を書いたSonnetの当初の見立てとが補い合った結果でもあり、こうした協働の形は、開発プロセスを主題にする第13章であらためて取り上げます。

FeedbackPolicyを、省略できない型にする

採用した解決は、FeedbackPolicyという型でした。

enum FeedbackPolicy {
    Read { source: ObservationSource, deadline: Duration },
    Blind { max_attempts: u32, backoff: Duration },
}

読み戻せる対象にはReadを、読み戻せない対象にはBlindを割り当て、新しいactuation経路を追加するときこの指定を省略するとコンパイルが通りません。2つの分岐がどこへ帰結するかは、次のように整理できます。

Read  → 実観測を確認 → 一致した        → Confirmed
                    → deadline超過    → (収束を確認できないまま終了)

Blind → 試行を送信   → max_attempts未満 → 再試行
                    → max_attempts到達 → GaveUp(観測ストアには書き込まない)

この設計を貫くのは、成功を偽装しない、しかし失敗も握りつぶさない、という思想です。進行中の試行はConfirmed(確認できた)かGaveUp(有界回数を尽くして諦めた)のいずれかに帰結し、GaveUpは観測ストアに一切書き込みません。書き込めば前節までの壊れ方が再発するからで、成功・失敗・不明という3値を明示的に扱っていることになります。

もう1つ、見落としがちな点がありました。この試行の状態を、observe tickのたびに新しく作り直してはいけません。check_drift_correctionnowと観測だけから毎回計算する純粋関数で、過去に諦めた記憶を持たないため、そのまま流用するとtickごとに新しい試行を構築しmax_attemptsが実質的にリセットされ、前節で見た無限再送が型を被って再発します。試行の状態を誰がどこで保持するのかという記述が欠けている、というCodexの指摘を受け、所有者をRuntimeとし、desired_openが変化したとき、フォーカスが変わったとき、ConfirmedGaveUpが確定したときにのみ破棄して作り直す設計に直しました。

この2分岐にたどり着くまでには、もう1つの案がありました。当初はRead/Blindに加えて、「読み戻せないが一定時間後は効いたはずとみなす」第3の分岐(AssumeAfter)も設ける予定でしたが、これを撤回させたのもCodexのレビューでした。Imm32Unavailableにも、開閉状態を裏づける信頼できる観測源はそもそも存在しない、という指摘です。GJIのI/Oカウンタからの間接観測はinput_mode判定専用で、開閉の観測ストアには最初から書き込まれない設計だったのです。「一定時間後は確定とみなす」という判定を観測への書き込みに横流しすれば、前節で見た偽装収束がそのまま再発しかねません。AssumeAfterは分岐として廃止され、Imm32UnavailableBlindに統合されました。

さらに大きな案として、読み戻せるかどうかで振る舞いを分けるという発想を型でなく実装そのものの分離にまで徹底し、3つのプロファイルごとに専用のコントローラを完全に分離する設計も検討されていました。最も設計として正直な形ですが、check_drift_correctionを3通りに分岐・重複実装するコストと、プロファイルごとに挙動が乖離していくリグレッションのリスクが大きく、今回は見送られています。FeedbackPolicyをデータとして渡す今回の方式は、この完全分離案が持つ利点の大半を、より小さい変更で得るための選択でした。

もう一つ、正反対の方向に振り切った案もありました。継続的な照合ループそのものを全廃し、全プロファイルをイベント駆動のactuationへ置き換える案です。現在の20ミリ秒間隔のループが、ImmCross側の実挙動を含めて実際に何を救っているのかを、まだ棚卸しできていません。棚卸しをしないまま廃止すれば、退行のリスクを読み切れないため、この案も今回は見送りました。

実装の途中で、もう1つ訂正が入りました。当初、GaveUpから復旧する条件は、targetと異なる値の観測が来たら、と設計していました。しかしIMEの開閉のようなbool値は、間違った値が1通りしかありません。乖離が続く限り、この条件はほぼ毎tick真になり、GaveUpをその場で無効化してしまいます。実装している最中に、この欠陥に気づきました。正しい復旧のシグナルは、観測の値ではなく鮮度です。GaveUpした時刻より後に、何らかの新しい観測が記録されたかどうかだけを見ます。これは第9章のエポックが扱った、観測の鮮度という主題と、そのまま地続きです。

試行状態の所有者の欠落とAssumeAfterの危うさは、どちらも実装が固まった後、read-onlyでレビューしたCodexが指摘して初めて拾われたものでした。AIの役割分担がどう効いたかは、第13章であらためて扱います。

単一の窓口へすべてのactuation呼び出しを統合する作業はまだ終わっておらず、ir_apply_drift_correction自身やplatform.rs/runtime/mod.rsのforce-on経路など既知5箇所の呼び出しは、代わりにcrate全体の呼び出し箇所数を凍結するテストで監視しています。

この設計はWindows実機でのソークテストをまだ実施しておらず、IME_ACTUATION_BLIND_MAX_ATTEMPTS = 5という試行回数の上限も裏付けのない暫定値です。

GaveUpとその後の扱いには、この章を書き終えた後も続きがありました。2026年8月、GJI再初期化(reinit)の完了後にromajiを再送するという案が一度検討されましたが、「完了を知らせる通知の経路がそもそも存在しない」という理由で却下されています(ADR-100)。却下と同時に、診断用のjournal記録という代替策だけは残されました。その後ADR-101は、却下の理由だった完了通知・フォーカス世代の照合・送信後処理・順序保証という4つの欠落を先に埋めたうえで、同じ発想のretryをあらためて実装しています。フォーカス世代を照合してから再送する仕組みは、次章で見るフェンシングトークンの空間版にあたり、遅れて届いた古いフォーカス先へ再送してしまう事故を防ぎます。設計文書どおりに実装したコードへの別レビューで、2件の実装ミスが見つかったという記録も残っています。設計と実装の間には、レビューという別の規律がなお必要でした。

この経緯からは、この章のFeedbackPolicyとは別の、開発プロセスそのものについての原則が引き出せます。一度却下した設計を、却下理由を潰さないまま再提案すれば、同じ議論を繰り返すだけです。却下理由を、二度と検討しないための蓋としてではなく、満たすべき条件のチェックリストとして残しておけば、条件が揃った時点で同じ設計を安全に採用し直せます。この再送についても、Windows実機での検証はまだ済んでいません。

この章が答えたのは、actuateした結果を観測にどう返すか、という問いでした。しかし、観測へ返ってきた証拠が本当にいま実行している試行の手柄なのか、という問いにはまだ答えていません。遅れて届いた確認が別の試行のものだった場合、awaseはそれをどう見分けるのでしょうか。

Physical AIへの接続

押せなかった、という事実も記録しなければならない

ロボット掃除機が、ドアを押して開けようとしている場面を想像してください。実際には押せていないのに、押せたことにして次のタスクへ進んでしまえば、ドアの前で同じ動作を延々と繰り返すか、逆に押せなかった場所を素通りしてしまいます。かといって、押せなかったときに何度も同じ力で押し続けるのも危険です。ロボットに必要なのは、押せたかどうかを確認できるなら確認してから次へ進み、確認できないなら有限回試して、押せなかったという事実を記録した上で諦める、という振る舞いです。洗濯機が蓋を閉めたつもりで、実際にはロックが掛かっていない場合も同じ構造です。閉まったことにして運転を始めるのは危険であり、かといって閉まるまで際限なく力をかけ続けるわけにもいきません。

これは実は、制御工学でclosed-loop制御/open-loop制御と呼ばれる区別と、同じ骨格を持っています。ただし本書がここで扱っているのは、対象を読み戻せるか否かという二値の設計判断そのものであり、制御工学が扱うゲイン設計・むだ時間補償・PID制御のような連続的な調整技法とは、水準が異なります。本書で「開/閉ループ」という言葉を使うときは、常にこの狭い意味に限定されています。

この区別が難しいのは、同じロボットの中でも、部位やセンサー構成によって「読み戻せるか」が一様ではない点です。関節の角度はエンコーダで確認できても、外界に対して実際に力が伝わったかどうかは、専用のセンサーが無ければ確認できません。1台のロボットの中に、Readで扱うべき対象とBlindで扱うべき対象が混在するとすれば、それはこの章でawaseが3つのアプリ分類へ同じループを当てはめてしまったのと、構造的には同じ状況です。

著者が別に取り組んでいるUnitree Go2 Runtime(ロボット向けアプリケーションランタイム)にも、自分のactuatorが起こした結果を読み戻せない場合にどう有界に振る舞うべきか、という同種の設計判断があります。この話は、終章で改めて短く触れます。


設計原則

原則: 対象を読み戻せるかどうかが、補正ループの設計を決めます。読み戻せるなら確認してから次に進み、読み戻せないなら有界な試行で明示的に諦めます。

適用条件: 望ましい状態と観測を比較して自動的に是正するループを、観測能力の異なる複数の対象へ一律に適用しようとするときに当てはまります。

実装の形: 諦め(GaveUp)を型で表現し、諦めた事実を観測ストアに書き込ませません。復旧条件は値の一致ではなく、証拠の鮮度で判定します。新しい経路を追加するときは、この方針の指定を省略できない型にします。

限界: 観測を書き込む側の偽装は、actuate側の型設計だけでは防げません。保証しているのはあくまで、有限回で終端すること(liveness)であり、状態が正しいこと(safety)を保証する仕組みとは別に用意する必要があります。加えて、この設計自体がWindows実機での検証を済ませていないことも、ここに記しておきます。