notes mizzy.org

差分計算は命令選択である

解釈 出典: explorations/2026-06-10-carina-type-system-type-theory/plan-as-compilation.md §3 作成/ 更新

IaCの差分エンジンがやっていることは、コンパイラの命令選択(instruction selection)の変種として読める。ターゲットの命令セットが固定されていて、ユーザーはその命令をどれも書かず、エンジンが自動的に選ぶ、という構造が同じだからである。

Carinaのターゲット命令セットは Effect の10種である。ただし命令選択の読みが素直に当てはまるのは、このうち差分エンジンが実際に選ぶ5種だけである。

ReadCreateUpdateDeleteWaitImportRemoveMoveDeferredCreateDeferredReplace

差分エンジンが選ぶ  /  残りはplan生成後の後処理が付け足す

ユーザーはこの5種をどれも書かない。差分エンジンがdesiredのノードとstateのノードを突き合わせ、各ノードに対して適切な命令を選択する。

残りは出自が違う。Import / Remove / Moveは差分エンジンが一切出さず、ユーザーが書いた import / removed / moved ブロックからplan生成後の後処理(add_state_block_effects)が付け足す。この後処理は差分エンジンが出した命令を撤回もする — removed の対象に対応するDeleteや、import の対象に対応するCreateを消す。つまりこの3種については「ユーザーは命令を書かない」が成り立たず、むしろユーザーが命令を直接指定している。命令選択という比喩が効く範囲はここで切れる。

命令選択として読むと、複合的な変更の扱いも位置づけがつく。ここで効くのがReplaceは命令ではないという事実である。Effect にReplaceという変種は無い。置換は差分エンジンが ReplacementGroup として組み立て、CreateとDeleteの対に展開される — 単一の論理的変更が複数命令の定型列に展開される、複合命令のloweringそのものである。コンパイラで一つの高水準演算が複数のマシン命令の定型パターンに落ちるのと同じ構図である。

ユーザーが書いた変更 name = "web" instance_type 変更 → 再作成必要 Replace — 命令は無い 差分エンジンが ReplacementGroup を組み、 下の命令列へ展開する delete-before-create — 共存できない場合 1 Delete 2 Create 停止時間が出るが、名前の衝突は起きない create-before-destroy — 先に作る場合 1 Create name = "web-tmp…" (一時名) 2 Update × 消費者 参照を新しい方へ向け替え 3 Delete 旧リソース 展開先に現れるのはCreate / Update / Deleteだけ。「Replaceを実行する」機構はどこにも無い。
置換のlowering。一時名が要るかはスキーマの unique_name が決める — 名前属性で一意なら要り、Coexisting なら要らず、Conflicting ならCBD自体がplan時エラーになる。

展開の形は2通りある。delete-before-create(DBC)は削除してから作る単純な列。create-before-destroy(CBD)は先に作るため、名前が衝突するなら一時名を与えて作り、参照している消費者をcascading updateで新しい方へ向け替え、最後に旧リソースを削除する。一時名が要るかはスキーマの unique_name が決める — 名前属性で一意なら要り、共存可能(Coexisting)なら要らず、そもそも共存不能(Conflicting)ならCBD自体がplan時エラーになる。「Replaceを実行する」機構はどこにも無く、あるのは展開結果の命令列だけである。

命令セットが型で狭められている例もある。基本実行器が扱えるのはCreate / Update / Deleteの3種だけで、これは BasicEffect という別の型で表現されている。以前は Effect を受けて非対応の変種を unreachable!() で弾いており、呼び出し側のフィルタ漏れが実行時パニックを招いた。narrowingを型に移したことで、新しい変種を足すと「基本か否か」を決めるまでコンパイルが通らない。typestateで壊れた状態を書けなくするの実例になっている。

普通の命令選択との違いは入力にある。コンパイラの命令選択はIRだけを見て命令を選ぶが、差分計算はdesired(ソース由来)とstate(世界由来)の二つを突き合わせて選ぶ。命令選択が二引数であることは、IaCのコンパイルが本質的に増分であることの現れである。

ただし「二引数」は概念の粒度での話である。実装の diff() は5引数を取り、desiredとcurrentのほかに saved(前回保存した属性)、prev_explicit(ユーザーが実際に書いた属性の木)、schema を受ける。後ろの3つは第三の入力というより、二つを比べる前にどちらを正規化するかの指定である — サーバ側が勝手に返す既定値や、ユーザーが一度も書いていないフィールドを差分に出さないために要る。二引数という読みは、この正規化を込みで「世界の側」を整えていると見るなら保てる。

粒度の違いはもう一つある。diff() が返すのはリソース単位の Diff(Create / Update / NoChange / Delete の4種)で、plan全体の命令である Effect とは別の型である。NoChangeがあることからも分かるように、こちらは「このリソースはどうなったか」の判定であって、実行すべき命令ではない。命令選択と呼んでいるのは、この判定を含みつつ、置換の展開・state操作・待機までを Effect の列に落とす工程全体である。

plan全体の入口 create_plan() は10引数を取る。「世界の側」も一枚岩ではない — current_states はrefresh済みのリモート状態であって、state fileそのものではない。state fileは saved_attrs / prev_explicit / directives_map という別々の引数として効く。desired対stateという二項対立は、実装では4つの役割に分かれている。

そしてProviderが create_plan() の引数に入っている。待機述語の順序ヒントを取るために、planの段階でProviderに問い合わせる。Providerを「動的段階の解釈器」とだけ見る読みは、ここで少し崩れる。命令を選ぶ側にもProviderが顔を出している。

出力側の性質も命令選択に対応する。選ばれた命令の列は純粋な値であり、実行は解釈器(Provider)が動的段階で与える。Planeffects: Vec<Effect> を持つserde直列化可能な構造体で、dyn Fn の類を一切抱えていない。

この「純粋な値」を自由モノイド(列)と呼ぶのは、ただし二点で言い過ぎである。第一に、Vec の順序は実行順ではない。実行順はapply時に apply_edges / destroy_edges からDAGとして導出される。順序が意味を持たない以上、モノイドの積(連接)に中身がない。第二に、DeferredCreateは template としてAST片を抱えており、apply後の上流stateに対して再展開される。関数ではないので値であることは保てるが、脱関数化された継続ではある。実装自身がこれを is_scheduler_meta() という述語で他と区別している。

「継続が隠れていない」が素直に成り立つのは、この2つのDeferred系を除いた部分である。除いた上でなら、planが検査可能・表示可能・保存可能であることの説明としては今も効く。

根拠の確度

以下は2026-08-16時点のソースで確認済み(carina-core/src/effect.rsdiffer/mod.rsdiffer/plan.rsplan.rscarina-cli/src/wiring/mod.rs)。

命令選択という対応づけ自体は解釈である。

初稿は命令セットを「Create / Update / Replace / Delete / Import / Remove / Move / Waitの8命令」と書いていた。Readと後から入ったDeferredCreate / DeferredReplaceが抜け、実装に無いReplaceが入っていた。加えて「ユーザーはこのどれも書かない」が全10種に掛かるように読めたが、Import / Remove / Moveはユーザーが書いたブロックが直接の出処である。Replaceを命令から外したことで、上のloweringの読みはむしろ強くなっている。

#plan #コンパイラ #差分