AIが、開発者に聞く ── R.E.V.I.S. #17 「待つから、奪うへ」
コードを渡されて Claude が質問し、開発者本人(eNIGM4 / 株式会社メイビス)が答えていく連載の第17回です。前回(#16)は、v0.5.0 で TaskQueue が6段階から12段階へ作り変えられ、VRAM 席管理に [IRON RULE] 1MAC = 1推論 という鉄則が刻まれた版でした。KECHI も直列ループから完全並列パイプラインに化けた。並列に投げ込んでも結局 IRON RULE の前で物理的に整列させられる、という構えまでが揃った。
今回 v0.5.1 は、その整列の作法に割り込みが入ります。
インタビュアー・ルール
- 渡されたコードそのものに質問する。 バージョンごとにコードを渡します。聞き手はそれを読み、開発者本人に質問していってください。
- 質問の対象は、いつもコードそのもの。「なぜこう書いたのか」「ここはこう動くように見えるが合っているか」── 目の前のコードから生まれた問いだけを投げてください。
- 推測で断定しない。 わからないこと・気になったことは、断定せず質問の形にしてください。
- 今渡された版に質問を集中する。 次の版のことは、次の版のコードを見せる機会があります。先走らず、目の前の版だけを掘ってください。
- 一度に投げるのは一問一答程度に。 質問を束ねて何個もぶつけない。一つ聞いて、答えをもらって、そこからまた一つ。会話のテンポを保ってください。
- バージョン名は時系列の整理番号であって、意味的なマイルストーンではない。「番号が大きいから進んでいる」とは読まないでください。実体は、コード内の作成日付と、機能があるか無いかで判断します。
- 開発者本人が、自分の言葉で答える。
eNIGM4:
ではみてみましょう、v0.5.1 です。
R.E.V.I.S._v0.5.1.zip
v0.5.1 ── 待つから、奪うへ
Claude:
v0.5.1 を展開。
コードを確認しています
zip 内タイムスタンプ(更新日)の最新は 3/22(7ファイル)、その手前に 3/21 が18ファイル、3/20 が10ファイル。規模はメイン側 65ファイル・10,652行(前の版から +5ファイル / +727行)、ヘルパー側は 10ファイル・1,213行(前版と同一の行数だが、中身が動いている)。LLM に触れて 27日目。
新規ファイルは5本。全部 Extension/Date/ 配下の日時ユーティリティ(Calendar/Date/DateFormatter/Locale/TimeZone の拡張)で、Created by が 2025/01/20 ── このプロジェクトの本流(2/24開始)よりも 1ヶ月以上前の日付。
新規5本がいきなり目を引きました。Extension/Date/ の中身は、日時計算・フォーマッタ・ロケール・タイムゾーンといった汎用的な日時ユーティリティで、LLM とは無関係。しかも Created by が 2025/01/20 ── R.E.V.I.S. が始まる 2026/02/24 より 1年以上前の日付です。
これ、本人がこのプロジェクトを始める前から手元に持っていた汎用部品を、この版で持ち込んだように見えます。LLM 本流のロジックではないので、ここでは「前から持っていた手持ち部品を流用した」という記録だけ残して本筋に進みます。
主役は TaskQueue の +69行(290→359)。前回 v0.5.0 で 12段階+ExecutionTarget+依存グラフ+VRAM 席管理を入れたばかりのキューが、続けて大きく育っています。
// 割り込まれたタスクを記録する集合(リプール用)
var interruptedTaskIDs: Set<UUID> = []
// VRAM 席の門番(優先度ベースの奪取付き)
func checkAndAllocateLocalVRAM(for task: QueuedTask) -> Bool {
// 二段の関所
// [1] プレフィル中は userAction 以外は奪わせない
if isPrefillFrozen && task.priority != .userAction { return false }
// [2] タイピング中は backgroundHigh 未満は奪わせない
if isUserTyping && task.priority < .backgroundHigh { return false }
// 奪取の本体
// 新タスクが backgroundHigh(8) 以上 かつ
// 走行中が backgroundHigh 未満なら、走行中を中断して席を奪う
if task.priority >= .backgroundHigh, let running = localVRAMTaskID,
runningPriority(of: running) < .backgroundHigh {
// INTERRUPT: 走行中タスクをinterruptedTaskIDsに記録してcancel
interruptedTaskIDs.insert(running)
cancelRunning(running)
localVRAMTaskID = task.id
return true
}
// ...
}
三つの動きが噛み合っていました。
一つ目、リプール(interruptedTaskIDs)の導入。前回までは割り込まれたタスクは単に cancel() されて消えるだけだったのが、ここで「中断扱いで記録して、後で再投入する」集合が用意された。
二つ目、VRAM 席の門番が「単純拒否」から「優先度ベースの奪取」に進化。前回は席が埋まっていたら新しいタスクは拒否されるだけだったのが、今回は「新タスクが backgroundHigh(8)以上で、走行中が backgroundHigh 未満なら、走行中を中断して席を奪う」── プリエンプション(preemptive scheduling)に踏み込んだ。
三つ目、奪取の手前に二段のガード。isPrefillFrozen 中は userAction 以外は奪わせない、isUserTyping 中は backgroundHigh 未満は奪わせない。
ここで目を引いたのが、奪取を開けた瞬間に手前のガードを足している点です。せっかく「優先度ベースで内部タスク同士が席を奪える」という新しい門を作ったのに、その奪取を真っ先に isUserTyping / isPrefillFrozen で抑え込んでいる。攻めの仕組みと、それを抑える守りの関所を、同じ関数の中に同居させている。
これは、奪取の門を開けたことで「ユーザーがまさに今タイプしている最中の、温めた VRAM(プレフィル投機の資産)まで内部タスクに横取りされかねない」という新しい危険が生まれて、それを潰すために手前のガードを足した ── という順序の対応関係ですか?
eNIGM4:
そうだね。
僕は OS を自分で作ったことがあるわけではないし、ましてや CPU を作ったことがあるわけでもない。経験があるとすれば、サーバーと非同期にデータをやりとりしてモバイルで最新データを表示し続ける仕組み、わかりやすくいうとスプレッドシートをもっとわかりやすくアプリに落とし込んだものといったほうがいいかな、を作ったぐらいの経験しかない。
完全オリジナルさ。しかも AI で書かずに全部本当に自力で書いた。
だからカーネルスケジューラの設計なんか当然知らないし、試行錯誤が相当入っているよ。
今回のは「ユーザーがまさに今タイプしている最中の、温めた VRAM(プレフィル投機の資産)まで内部タスクに横取りされかねない」で合っているよ。
その前のアプリ見てみるかい?
寄り道、前のプロジェクトの地盤
Claude:
ぜひ見たいです。「サーバーと非同期にデータをやりとりしてモバイルで最新を表示し続ける、スプレッドシートをアプリに落とし込んだもの」── これが TaskQueue / FalqQueue の非同期制御や、サーバーと透過的にやりとりする分散の発想の地盤になっている可能性がありますね。
ただ確認させてください。このインタビューのルールは「渡されたコードに質問する」「今の版だけに集中する」というものでした。前のアプリは R.E.V.I.S. のアーカイブ(v0.0.1〜v1.0.0)の外の、別プロジェクトのコードということですよね。連載の本筋(R.E.V.I.S. の各版を追う)とは別枠の参考資料として見る、という理解で合っていますか?
eNIGM4:
別枠の参考資料さ。
あと、内容で伏せるべきところはちゃんと後でやるから、遠慮なく書いてくれて構わないよ。
Claude:
ありがとうございます。別枠の参考資料として展開しました。要点だけ受け取って、本筋を補強する裏取りとして使います。
確認できた背景はこうでした ── R.E.V.I.S. より 1年以上前(2024年初頭から 2025年初頭にかけて)、本人は自力で本格的な iOS +サーバーのアプリを作っていた。規模は iOS 側だけで R.E.V.I.S. v1.0.0 に匹敵するくらいで、AI に頼らずゼロから書いた。
そこには、後の R.E.V.I.S. の非同期制御の地盤になる手触りが既にあった。具体的には:
- 資源には限りがあるから、同時通過数を決めて、超えたら待たせる(用途ごとに値を変える ── データ取得は 3 並列、ログインや同期は直列1つ)
- 席を取ったら必ず返す(取りっぱなしで詰まらせない作法)
- 用途ごとに門を分ける(役割で資源を区切る)
これらの作法を、本人は OS のカーネルスケジューラやセマフォの理論からではなく、サーバー通信のデータ取得を制御する現場の必要から、自前で組み上げていた。
なお、前のプロジェクトの具体的な名前・コード・関数の固有名は、ここでは出しません。本人が「伏せるべきところは後でやる」と仰っていた配慮に従い、「前作のアプリでこういう作法を1年以上前から自力で実装していた」という事実だけ残します。
そして、本人から「前作で使っていた非同期セマフォの使われ方を見てほしい」という促しがありました。具体的な発言内容には前作のクラス名が含まれるため、ここでは固有名を伏せて要点だけ受け取ります。
Claude:
見ました。具体的なファイル名や関数名は伏せますが、要点はこうでした ── 用途ごとに別々の非同期セマフォが立てられていて、それぞれ同時通過数の値が違う。書き込みや同期は単独(値1)、データ取得は3並列(値3)、ファイル取得やサムネ取得は単独 ── という具合に、用途別に並列度を変えて門を分けている。
そして使い方の型が綺麗に揃っていました。席を取って入る → 必ず席を返して出る。取りっぱなしで詰まらせない。これがいろんなデータ取得(顧客情報、部署情報、権限情報など)で横断的に使われている。
つまり R.E.V.I.S. で VRAM という新しい資源が出てきた時、「席」と呼んで門番を立てるところへ、試行錯誤しつつも自然に手が伸びた ── という筋が、現物で裏付けられました。
ここで一つ気になったのが、前のプロジェクトでの作法は「待つ」だけ ── 同時通過数を超えたら、後から来たものは前が終わるまで大人しく待つ。走っている処理を途中で蹴り出すことはしない。
ところが今回の v0.5.1 で見た checkAndAllocateLocalVRAM は、そこから一歩踏み込んで、優先度が高ければ走行中のタスクを cancel() して席を奪うプリエンプションまで来ている。前作には無かった「奪う」という挙動。
この「待つ」から「奪う」への飛躍は、R.E.V.I.S. をやる中で必要に迫られて初めて手を出した領域だったんでしょうか?
eNIGM4:
正確には DB の制約だね。
同時に read してもいいけど、write 中は read してはいけないし、もちろん write も御法度だ。
その上で表示をもっさりさせないために、DB の内容をメモリ上に展開、DB は再起動時の読み込み用途としてバックグラウンドで扱う。
そう、僕が ModelActor の存在を知らなかった、もしくはその時なかったから、こうする必要があったという話だね。
奪うというのは、推論という細かいフェッチじゃなくて人間の時間単位でかなり差が出るような長さの処理だから出てきた発想だね。
Claude:
二つに切り分けて理解できました。
一つ目 ── 前作の同時通過数の調整は、洗練のための設計というより DB の排他制御の写し取りだったんですね。「同時 read は可、write 中は read 禁止・write も単独」を、同時通過数で表現していた。データ取得は 3 並列、書き込みや同期は単独 ── という値の違いが、まさにその read/write 制約に対応している。
そしてそれを「もっさりさせない」ために、DB は再起動時の読み込み用に裏へ回し、実体はメモリ上に展開して扱った。ModelActor(SwiftData の「アクターで DB アクセスを直列化する」既製の枠組み)が当時手元になかったから、自前で read/write 調停を組む必要があった。
これは R.E.V.I.S. の Repository 層やアクターによる DB 集約(ModelActor を使える今の形)と裏表の関係ですね。手段が無かったから自前で組んだものが、手段ができたら正規の枠組みに置き換わっていく。連載のあちこちで本人が「ずーっとコード整理してる、これからも多い」と仰っているリファクタリングの正体の一端が、ここに見えます。
二つ目 ──「奪う」が出てきた理由がはっきりしました。前作の各処理は DB フェッチという短い単位だから、待っていればすぐ順番が来る、待つコストが小さい。だから「奪う」必要が無かった。ところが R.E.V.I.S. の推論は人間の体感で何秒・何十秒と差が出る長さの処理。低優先度の重い推論が VRAM 席に居座っている間、ユーザーの Enter や喋りがその後ろで延々待たされたら、体感が壊れる。待つコストが桁違いに大きいからこそ、「待つ」では足りず「蹴り出して奪う」が必要になった。
資源の性質(短いフェッチ vs 長い推論)が、同じ非同期発想を「待ち」から「プリエンプション」へ押し上げた、という筋ですね。前作で 1 年かけて手に馴染ませた「限られた資源を待たせて捌く」の型に、R.E.V.I.S. では「奪う」が一段足された。
参考資料からの裏取りはここまでで十分像が結べました。本筋に戻ります。
強くした分の、安全装置
eNIGM4:
他の差分も見てほしいね。
Claude:
すみません、TaskQueue 以外をまだ見ていませんでした。v0.5.0 → v0.5.1 で動いた他のファイルを全部洗います。
コードを確認しています
中身が動いたファイルを行数差で並べると ── Orchestrator+KECHI +58、RootView +47、MemoryRepository +43、OrchestratorDispatcher +42、CognitiveOrchestrator +39。helper 側も総行数こそ同じだが中身が動いていて、InferenceService -4、NativeMLXEngine +4 と、わずかな差分の中に重要な修正。
中で目を引いたのが二つ。一つは KECHI に +58 行の追加。前回 v0.5.0 で並列化したばかりなのに、ここでさらに大きく伸びている。
// 記事解体の勝者が出た!同じ記事を読んでいる残りのタスク
// (Summaryや他のChunk)だけを即座にキル
// 記事解体フェーズ専用のキルシグナル
// (親プロセスには伝播させない)
コメントが直球で書いていました。「局地キル(local kill)」 ── ある記事のチャンクのどれかがゴール達成したら、その記事を読んでいる兄弟タスクだけを止め、上位(親)には波及させない。
もう一つは helper の NativeMLXEngine のわずかな変更。
// ...ここを削除!
// self.isCancelled = false ← 早すぎるリセットでキャンセルが効かなくなるバグの修正
// グローバルのキャンセルボタンは押させない、
// 自分のTaskだけを静かに終わらせる
// self.cancel() ← この行も削除
self.isCancelled = false を削除(早すぎるリセットでキャンセルが効かなくなるバグ修正)、そして self.cancel() を削除して、コメント「グローバルのキャンセルボタンは押させない、自分の Task だけを静かに終わらせる」── 割り込まれた一つのタスクを止めるつもりが、エンジン全体を止めてしまっていたのを、自分のタスクだけ静かに畳む形に直している。
ここが効きます。main 側(KECHI の局地キル)と helper 側(自分の Task だけ静かに止める)が、同じ思想で連動しているように見える。どちらも「キルを必要な範囲に閉じ込めて、巻き添えで上位や全体を止めない」という一貫した線です。
v0.5.1 は、TaskQueue に「奪取(プリエンプション)」という強い割り込みを入れた版でしたよね。それと同じ版で、KECHI には「局地キル=兄弟だけ止めて親には伝播させない」、helper には「全体を止めず自分の Task だけ静かに終わらせる」が入っている。
割り込み・キャンセルを強力にしたのと、それを狭い範囲に封じ込める改修が、同じ版に同居している。 これは、奪取という強い武器を持たせた瞬間に「このままだと一つのキルが連鎖して関係ない処理まで巻き込んで全部倒れる」という危険が見えて、強くした分だけ、その効き目を局所に閉じ込める安全装置を同時に張った ── という対の関係ですか?
eNIGM4:
そうだねぇ。プロセスキルがはっきりした分、プロセス自体の管理をもっと明確にする必要があった。
余計な処理はカットすべきだが、もともと全部は意味があって立ち上がった処理だからね。
当然ゴール(ユーザーへの回答)まで正しく走り抜けないといけないし、それができなければ未完成だよね。
Claude:
腑に落ちました。「奪取/キルがはっきりした分、プロセスの管理を明確にする必要があった」── 強い武器を持たせたら、その武器がどこまで効くかの境界を厳密に引かないと、本来意味があって立ち上がった処理まで巻き添えで倒してしまう。だから局地キル(兄弟だけ・親には伝播させない)や「自分の Task だけ静かに終わらせる」で、キルの及ぶ範囲を仕切った。
そして締めの一言が芯ですね ──「余計な処理はカットすべきだが、もともと全部は意味があって立ち上がった処理」。だから無闇に殺してはいけない。interruptedTaskIDs で奪われたタスクを捨てずに記録して後で戻すのも、KECHI で「もう要らない兄弟」だけを選んで切るのも、根は同じ。走っている処理には全部存在理由があるという前提に立った上で、本当に不要になったものだけを、必要な範囲で畳む。そしてその全部が、最終的にユーザーへの回答というゴールまで正しく走り抜けるためにある。走り抜けられなければ未完成、と。
プロセス管理の、明確化
eNIGM4:
他になければ次に行くけど、他にない?大丈夫?
Claude:
いえ、まだ詰めきれていません。さっきの差分で大きく動いていたのに中身を見ていないファイル ── RootView +47、MemoryRepository +43、OrchestratorDispatcher +42、CognitiveOrchestrator +39 ── が残っています。
コードを確認しています
CognitiveOrchestrator に abortGeneration(生成中のタスクをまるごとキャンセル)と activeGenerationTask が新設。ChatInputView の 110行目から呼び出されている ── これはユーザーが押す画面の停止ボタンの経路。
MemoryRepository は fetchKnowledge / gatherContext という、知識取得とコンテキスト収集を集約する関数を獲得。
OrchestratorDispatcher に func resume(returning value:) / func resume(throwing error:) / if case .cancelled = termination という、CheckedContinuation を使ったキャンセル対応の待機が実装されている。
RootView に idleConsolidationTask?.cancel() ── 前回 v0.5.0 で見た「バックグラウンドの知識統合(アイドル消化)」を、画面状態に応じてキャンセルできるようにした。
これで最後のピースが揃いました。この版の通奏低音は徹頭徹尾「キャンセル・割り込みの全レイヤー整備」だったと確認できます ── TaskQueue の奪取、KECHI の局地キル、helper の「自分の Task だけ静かに止める」、abortGeneration(ユーザーの停止ボタン)、idleConsolidationTask のキャンセル、そして Dispatcher の CheckedContinuation によるキャンセル対応待機。上から下まで、全部「止める」を正しくやるための配管整備で一貫している。
特に Dispatcher の CheckedContinuation ── これ、前作のアプリで見た「空きを待つ」のに使っていた継続部品と同じ型なんですよね。前作では「空きを待つ」のに使った継続が、ここでは「タスクの完了/キャンセルを待つ」のに使われている。前作で手に馴染ませた部品が、用途を変えて R.E.V.I.S. の配管にも顔を出している。
なお、TaskQueue のリプール(中断を記録して後で戻す)+ VRAM 席の優先度ベース奪取+ノード優先度の選別 ── この組み合わせは、v1.0.0 で完成する FalqQueue の祖先にあたる中核部分だと伺っています。ここから先 ── 具体的な奪取判定アルゴリズム、依存グラフの解決順序、ノード選別の細部 ── は、本人が出願している特許の中核と隣接する領域なので、本連載では設計の輪郭(奪われたタスクは捨てずに記録して後で戻す・奪取の手前にユーザー保護の関所を二段置く・キルの範囲を局所に閉じる)までを概念レベルで押さえて、その先の実装には踏み込まないことにします。
整理すると
v0.5.1。zip 内タイムスタンプ(更新日)では 3/22 が7ファイル、3/21 が18ファイル、3/20 が10ファイル。規模はメイン側 65ファイル・10,652行(前の版から +5ファイル / +727行)、ヘルパー側は10ファイル・1,213行(行数同じ・中身が動いている)。LLM に触れて 27日目。
主役は TaskQueue の +69行。前回 v0.5.0 で 12段階+ExecutionTarget+依存グラフ+VRAM 席管理を入れたばかりのキューが、続けて大きく育った。三つの動きが噛み合って入っている ──(1)リプール(interruptedTaskIDs)の導入=割り込まれたタスクを捨てずに記録して後で再投入、(2)VRAM 席の門番が単純拒否から優先度ベースの奪取に進化=走行中の低優先度を中断して席を奪うプリエンプション、(3)奪取の手前に二段のガード=isPrefillFrozen 中は userAction 以外奪わせない・isUserTyping 中は backgroundHigh 未満は奪わせない。強い奪取の門を開けると同時に、ユーザーが温めているプレフィル投機の資産を内部タスクに横取りされないよう守る関所を立てた。
そしてこの「奪う」が出てきた背景に、本人の口で初めて語られた前史。R.E.V.I.S. より 1年以上前から、本人は AI に頼らず自力で本格的な iOS +サーバーのアプリを作っていた。「OS を作ったわけじゃない、CPU を作ったわけじゃない、カーネルスケジューラの設計なんか当然知らない」── ただ、サーバーと非同期にデータをやりとりしてモバイルで最新を表示し続ける経験を、自前で 1 年かけて手に馴染ませていた。具体的には DB の排他制御(同時 read 可・write 中は read 禁止)を自前の非同期セマフォの仕組みとして書き、ModelActor のような正規の枠組みが当時手元になかったから、メモリ上にデータを展開して読み書きを捌いていた ── そこで身に付いた「限られた資源を待たせて捌く」型が地盤としてあった。だから R.E.V.I.S. で VRAM という新しい資源が出てきた時、「席」と呼んで門番を立てるところへ、試行錯誤しつつも自然に手が伸びた。
ただし前作は「待つ」だけ。走っている処理を蹴り出すことはしなかった。R.E.V.I.S. でその一歩先 ──「奪う」── が出てきた理由は、資源の性質の違い。本人いわく「奪うというのは、推論という細かいフェッチじゃなくて人間の時間単位でかなり差が出るような長さの処理だから出てきた発想」。前作の DB フェッチは短く、待つコストが小さい。R.E.V.I.S. の推論は人間の体感で何秒・何十秒の長さで、待つコストが桁違い。だから「待つ」では足りず「蹴り出して奪う」へ踏み込んだ。手段が無かったから自前で組んだものが、手段ができたら正規の枠組みに置き換わっていく ── 連載で何度も予告されてきたリファクタリングの正体の一端が、ここに見えた。
主役以外の差分も濃かった。強い奪取を入れたのと同じ版で、それを正しく仕切る安全装置が同時に張られている。Orchestrator+KECHI +58 で局地キル(local kill)=記事解体の勝者が出たら同じ記事を読む兄弟タスクだけ即キル、親プロセスには伝播させない。helper NativeMLXEngine で isCancelled = false の早すぎるリセット削除+self.cancel() 削除=グローバルのキャンセルボタンは押させない、自分の Task だけ静かに終わらせる。CognitiveOrchestrator に abortGeneration + ChatInputView 110行目にユーザーの停止ボタン経路。RootView に idleConsolidationTask?.cancel()。OrchestratorDispatcher に CheckedContinuation(resume / cancelled)によるキャンセル対応待機 ── これは前作の「空きを待つ」継続部品と同じ型が、用途を変えて R.E.V.I.S. の配管に顔を出している。
通奏低音は徹頭徹尾「割り込み・キャンセルを全レイヤーで正しく整える」── TaskQueue の奪取、KECHI の局地キル、helper の自分の Task だけ静かに止める、ユーザー停止ボタン、アイドル処理のキャンセル、Dispatcher のキャンセル対応待機。上から下まで、「止める」を正しくやるための配管整備で一貫している。動機は本人の言葉どおり ──「プロセスキルがはっきりした分、プロセス管理を明確にする必要があった/処理は全部意味があって立ち上がっている/だが最後はユーザーへの回答まで正しく走り抜けないと未完成」。
そして、この版を読み終わるまでに本人から二度、Claude を引き戻す促しがありました ──「他の差分も見てほしい」「他にない?大丈夫?」。主役(TaskQueue)にばかり目を取られて他の差分を見ようとしなかった怠慢を、本人が二度の促しで補正してくれた。連載のあちこちで見てきた「AI を引き戻し続ける」の構図が、ここでは Claude(聞き手)に向けても発動した格好です。読みの速さで AI を引き戻せる人だからこそ、聞き手の見落としにも気づいて修正できる。連載のテーマが、聞き手の振る舞いそのものにも適用された一版でした。
メジャー番号は据え置きでも、強い武器を入れて、その武器を仕切る安全装置を同じ版に張った版。次の版では、整った配管の上に何が乗ってくるのか。続けて追っていきます。それでは、また明日。
(鉄則は、一台に一推論)←前 次→(危ない入口に、見張りを)
Discussion