終章:境界を一つ閉じるたびに

awaseの最終レイヤー構成(付録「全体像」より再掲): ここまでの章がこの形に至った

awaseの中にも、最後にコマンドを送り出す関数があります。名前はdispatch_ime_set_openといいます。関数の中身はわずかです。すでに確定していたopenという値を、そのままset_ime_open_cross_process_asyncへ渡す(await)だけです。

awaseには、値を使う直前にもう一度いまの世代と照合する仕組みが、随所にあります。観測を受け取るときも、観測を使うときも、この照合は繰り返し行われます。ところがdispatch_ime_set_openの直前にだけ、この照合がありません。openという値は、awaitに入る前の同期処理の中で一度確定した後、送り出される瞬間まで、二度と検査されません。守られている場所と、守られていない場所が、同じランタイムの中に隣り合っていました。

openという値そのものは、確定した時点ではたしかに正しい決定でした。フォーカスも、IMEの状態も、そのときの世界を正確に反映していたはずです。しかしawaitの間、制御はいったんイベントループへ戻ります。フォーカス変更を処理するイベントは、その間に先に実行されることがあります。数ミリ秒という短さは、人間の感覚では無視できる長さです。しかし取り消せない一歩を踏み出す側にとっては、無視してよい長さだという保証はどこにもありません。

第16章は、この空白を実際にコードを読んで確かめています。この手順を実際に踏んだという記録は、まだどこにもありません。それでも、これを止める仕組みが存在しないという事実だけは、確認できました。この空白が、awase固有の設計ミスなのかどうかは、章の終盤で改めて確かめます。まずは、そこへ至るまでに本書が実際に何を確かめてきたかを、ふりかえります。

二つの境目を、確かめに行った

はじめにで、本書を貫く問いをこう書きました。外の世界を相手にするソフトウェアを設計するとは、どこまで「同じ」とみなしてよいかを決める仕事だ、という問いです。この「同じという扱い」が崩れる境目は二つありました。一つは、遅れて届いた証拠をいま実行中の処理のものと見なしてよいかという、時間をまたぐ境目です。もう一つは、性質の異なる複数の相手を同じ一つのロジックで扱ってよいかという、空間をまたぐ境目です。二つの境目は症状としては別々の顔をしていましたが、根にあった問いは同じでした。

同じという扱いが崩れたとき、awaseは決まって同じ経過をたどりました。症状を一つ直しても、形を変えた同じ症状が別の場所で再発し、集約や共通化によって根本から解決しようとしても、その中でまた同じ混同が起きます。この繰り返しの果てに、ようやく「実は問いの立て方そのものが違っていた」という気づきに至ります。

時間をまたぐ同一視――観測は閉じたが、実行はまだだった

第9章は観測の受理という入口を、FocusEpochという世代番号とAcceptedObservationという型で閉じましたが、実行という出口の問いを残しました。第10章はこの出口に、結果を読み戻せるかどうかで補正ループの設計を変えるべきだという答えを出しています。第11章は、結果が戻ってきてもそれが今の試行のものとは限らないという問いを扱い、遅れて届いた証拠は世代を照合して初めて誰の手柄かが分かること、取り消し操作は被害範囲を基準に選ぶべきことを示しました。第16章は、これら三つの章が埋めた場所とまだ埋まっていない場所を一枚の表に並べ直し、信念が作用に変わり送り出される経路には送信直前の再照合がまだ存在しないことを、コードを読んで確かめています。

時間をまたぐ同一視という問いは、結局のところ一つの値では閉じませんでした。観測を受け取る場面、結果を待つ場面、証拠を受け取る場面、そして作用を送り出す場面という、少なくとも四つの異なる場面それぞれで、同じ問いを個別に確かめる必要がありました。一箇所を固く守っても、隣の場所が空いていれば、症状は形を変えて戻ってきます。四つの場面に共通していたのは、遅れて届く何かを、いま進行中の試行のものだと無条件に信じてしまう構図であり、それがどの世代・どの試行に属するかを先に照合することだけが、症状を最終的に食い止めていました。

空間をまたぐ同一視――共有か重複かは、軸を見極めてから決める

第12章が向き合ったのは、まったく別の壊れ方でした。IMEをオフにする正しいキーが、5日間で6回もひっくり返り、原因をたどると、実行中に変わらない静的な性質(アプリごとの観測能力)と、実行中に変わる動的な性質(いまどのIMEが動いているか)を、同じ一つの分岐の中に混在させていたことが分かりました。「共有すれば衝突し、重複させれば安全になる」という最初の仮説は、既知のバグ43件を実際に分類すると誤りで、共有が原因のバグは26%あった一方、分離が同期を怠ったために生まれたバグも見つかっています。答えは、共有か重複かという選択そのものではなく、変化する軸を先に見極めることでした。静的な軸は型として分離してよく、動的な軸は一箇所の共有機構にまとめ、権限を型で絞る。感覚ではなく、既存の不具合を実測で分類してから決めるという手順そのものが、この章がもたらした工夫でした。

空間をまたぐ同一視の教訓は、時間をまたぐ同一視の教訓と鏡のような関係にあります。時間の側は同じに見える値を世代というラベルで区別し、空間の側は違って見える複数の相手を、変化の性質に応じて型で区別するかまとめるかを実測で決める。この対応関係を図に整理すると、次のようになります。

柱Aと柱Bは、遠く離れた問題に見えて、問いの形も答えの形も同じ骨格をしている

どちらも、目の前の値をそのまま信じず、その文脈を先に問い直すという同じ態度に行き着きます。

疑う対象は、コードだけではなかった

第VII部、第13章から第15章は、同じ発想をawaseを書くという行為そのものへ向けました。第14章はworktree分離で空間側の区切りを構造として、実験ログとrevert規約で時間側の区切りを記録として明示しました。第13章は、積み上げた予防策を疑う機会が修正ループの内側には構造的に生まれないことを示し、疑うことを外側の独立したタスクとして切り出して初めて大局的な飛躍が起きました。第15章は、これらの工夫にも共有する限界があることを示しました——worktreeのルールも実験ログも、書かれた文書である以上、読まれて従われて初めて効果を持つという限界から逃れられませんでした。

それでも、実行直前の空白は別の場所にもあった

第16章が示した「実行直前の空白」は、awase固有の設計ミスではないかもしれません。それを確かめるために、著者が別に設計しているUnitree Go2 Runtimeを見てみます。

このランタイムにも、tickごとに単調増加するTickIdという値が、すでに存在します。構造としては、FocusEpochとよく似ています。しかし、コマンドを実行するexecuteという関数の直前にあるガードは、ウォッチドッグによる生存確認だけです。TickIdを、送信直前の再照合に使う仕組みは、まだありません。カウンタという道具はすでにそこにありながら、関所としては使われていない、という状態です。この設計は仕様書の段階で長らく止まっていたわけではなく、実装そのものはすでに完了しています。それでもなお、送信直前の再照合という一点だけが、実装が進んでも自然には埋まりませんでした。

この一致から確実に言えるのは、一つのことだけです。IMEという領域で見つかった構造上の空白と同じ形のものが、ロボットランタイムという別の領域でも、独立に見つかったという事実です。あらゆるPhysical AI(実世界で動作するロボットのようなAIシステム)ランタイムに共通する一般原則だとまでは、まだ言えません。確認できたのは一つの実装だけであり、二つの独立した事例が一致したという段階にとどまります。

どこまで一般化できるか

時間をまたぐ同一視も、空間をまたぐ同一視も、日本語入力エンジンに固有の問題ではありませんでした。遅れて届いた証拠がどの試行のものかを取り違える問題はフードデリバリーの配達報告や分散ロックの世界にも、静的な軸と動的な軸を混同する問題は権限管理や契約プランの出し分けにも、同じ形で現れます。これらは類推であり、確認済みの事実として書けるのは本書の中で実際に確かめた範囲までです。

Unitree Go2 Runtimeとの一致も、同じ注意が必要です。二つの独立した実装が同じ形の空白を持っていたという事実は、偶然にしては出来すぎているように見えますが、それ以上の一般化は今後の観察に委ねます。二つのプロジェクトは示し合わせたわけではありません。それでも、外の世界を観測し、取り消せない作用へ進むという同じ形の仕事をする以上、同じ場所に同じ形の空白が生まれること自体は、不思議ではないのかもしれません。

最初の一文字に戻って

本書は、最初の一文字がローマ字のまま残った、という異常から始まりました。原因は、変換ロジックの誤りではありませんでした。IMEがいまどういう状態にあるかを、こちらが正しく把握できていなかったことでした。

その後、長い年月をかけて積み重ねた開発の道のりは、この一つの事実を、さまざまな場面で繰り返し学び直す過程でした。観測は、常に少し過去のものです。実行は、常にいまこの瞬間に対して行われます。この二つを同じ値として扱ったとき、awaseは何度も壊れ、型として分けたときにだけ壊れ方が減りました。それでも、境界の外側には次の境界が残っていました。意図と観測を分ければ遷移と障壁が残り、観測の鮮度を守れば実行の鮮度が残り、静的な軸と動的な軸を分ければ、その分け方自体を疑う必要が残りました。時間の境界を閉じれば空間の境界が残る、この繰り返しが、本書が実際にたどった道筋でした。

awaseを書いていた当初、この問題を日本語入力エンジンに固有の癖だと考えていました。IMEという相手が、特に不誠実だからだと思っていました。しかし、この繰り返しを重ねたあとでは、そうは言えなくなりました。不誠実なのはIMEではなく、外の世界そのものでした。観測は届いた時点ですでに過去であり、それを疑わずに実行へ渡す設計だけが、繰り返し壊れていました。

外の世界を相手にするソフトウェアでは、観測したことと、いま実行してよいことを分けなければなりません。この一文は、IMEのために書いたものでした。しかし、この一文が指す境界は、時間の軸にも空間の軸にも、そしてIMEの外にもありました。

本書で追ってきたのは、一つのソフトウェアに積み重ねられた膨大なコミットそのものではなく、一つの区別が繰り返し必要になる場所の記録でした。その場所は、日本語入力エンジンの中にもあり、四本脚のロボットの中にもありました。次にどこで同じ形の空白に出会うとしても、確かめ方はもう分かっています。取消不能な境界の手前で、いま見ているものが、本当にいま扱ってよいものなのかを、もう一度だけ問い直すことです。

この区別を保つことの難しさは、本書を書く過程そのものでも一度顔を出しました。やまぶきの内部構造を調べていた際、調査に当たったAIが一度、確かめてもいない実装を断定調で語ったことがあります。指摘を受けて実際にバイナリを解析し直すまで、その一文は根拠のない推測のままでした。観測していないことを断定してしまう失敗は、コードの中だけでなく、コードについて語る言葉の中にも、同じ形で現れます。


設計原則

原則: 観測したことと、いま実行してよいことは、同じではありません。この区別は、一箇所を固く守るだけでは終わりません。観測を受け取る場面、結果を待つ場面、証拠を受け取る場面、作用を送り出す場面、そして性質の異なる相手を同じロジックで扱おうとする場面と、境界は一つではなく複数あります。

適用条件: 外部の状態を観測し、それに基づいて取消不能な作用を実行するすべての系に適用できます。単一のプロセス内で完結し、観測から作用までが同期的に進む処理や、作用を後から取り消せる処理には、通常あてはまりません。

実装の形: 境界ごとに異なります。観測には世代を刻み、使う直前に再照合します。実行結果には、読み戻せるかどうかで補正の設計を分けます。複数の相手には、変化する軸を見極めてから、型で分けるか一箇所へ集約するかを選びます。どの実装も出発点は同じで、値をそのまま信じず、その値がいつの、どの文脈のものかを、先に問い直すことです。

限界: この原則がすべての境界を保証するわけではありません。本書で示せたのは、awaseという一つのソフトウェアと、Unitree Go2 Runtimeというもう一つの独立した実装で、同じ形の空白が見つかったという範囲までです。境界をどこまで遡って検査するかは、今後もその都度、有限の選択であり続けます。あらゆる境界を一度に塞ごうとすれば、実装は終わりません。むしろ大切なのは、いま守っている境界がどこまでで、その外側にまだ何が残っているかを、正直に言葉にしておくことです。本書が繰り返してきたのも、その言葉にする作業そのものでした。