差分計算は命令選択である
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そのものである。コンパイラで一つの高水準演算が複数のマシン命令の定型パターンに落ちるのと同じ構図である。
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)が動的段階で与える。Plan は effects: 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.rs、differ/mod.rs、differ/plan.rs、plan.rs、carina-cli/src/wiring/mod.rs)。
Effectの10変種と、Replaceが変種として存在しないこと。実装には変種を数えるassert_eq!(effects.len(), 10, …)があり、変種を足すと落ちる- 差分エンジンが出すのはRead / Create / Update / Delete / Waitの5種で、Import / Remove / Moveは
add_state_block_effectsが後から付け足すこと。この後処理が既存の命令を撤回もすること ReplacementGroupによるCBD/DBCの展開順、一時名の生成条件とunique_nameの分岐BasicEffectによる3変種への絞り込みと、それがunreachable!()を置き換えた経緯diff()の5引数、create_plan()の10引数、Diffの4変種、current_statesがrefresh済みリモート状態であること、Providerがcreate_plan()の引数であることPlanがVec<Effect>を持ちserde直列化可能でdyn Fnを含まないこと。実行順がapply_edges/destroy_edgesから導出されること
命令選択という対応づけ自体は解釈である。
初稿は命令セットを「Create / Update / Replace / Delete / Import / Remove / Move / Waitの8命令」と書いていた。Readと後から入ったDeferredCreate / DeferredReplaceが抜け、実装に無いReplaceが入っていた。加えて「ユーザーはこのどれも書かない」が全10種に掛かるように読めたが、Import / Remove / Moveはユーザーが書いたブロックが直接の出処である。Replaceを命令から外したことで、上のloweringの読みはむしろ強くなっている。