icon_sc.core.plan — execution plans

Execution plans: negotiation/execution split, op algebra, T1 interpreter (§8.2-§8.3).

Bind-time plan compiler: the §8.2 negotiation/execution split (SPEC S05).

ExecutionPlan.bind(composition, schema, ctx) walks the composition tree via the visit(plan_builder) double-dispatch protocol implemented by every S03/S04 container, runs the full sympl negotiation exactly once (S02/S03 DynamicChecker/IngressPlan machinery, one negotiation per component occurrence) and emits symbolic op drafts over a stable slot table. Federations and pure-control-flow wrappers dissolve (§8.2):

  • SequentialUpdateSplitting flattens into the op list;

  • ParallelSplitting’s recombination ψⁿ⁺¹ = Σψₗ - L·ψⁿ becomes one k-ary Axpy per field, term order replayed from the T0 implementation (seed ψ_l1, then per further section +ψ_l, -ψⁿ) so T0≡T1 stays bitwise;

  • SequentialTendencySplitting gets provisional slots plus one DiffScale per stepped field per section;

  • SSUS doubles into the reversed λ·Δt pre-list, the core, and the forward (1-λ)·Δt post-list;

  • CallingFrequency becomes per-signature cadence masks with its cached output living in persistent vault/tendency slots;

  • Subcycle and DynamicalCore stage/substep tiers unroll with bound dt;

  • ScalingWrapper folds into constant-coefficient Axpy ops.

State evolution is in-place where elementwise-safe (tendency integration) and ping-pong for kernel-written outputs: every swapped field owns exactly one (vault cell, alternate cell) pair, and the compiler emits one op-list variant per plan phase (even/odd x cadence signature) with buffer references pre-bound — no runtime indirection (§8.2). run_step interprets the phase’s variant; plan_hash is a stable content hash of the symbolic plan.

class icon_sc.core.plan.bind.ExecutionPlan(compiler)

The frozen product of one bind (frozen interface, SPEC S05).

ExecutionPlan.bind(composition, schema, ctx) compiles; run_step(vault, step_index) interprets the pre-bound op variant of the step’s signature (T1). Materialization against a concrete vault happens lazily on the first run_step (buffers become available only then); plan_hash and signatures are properties of the symbolic plan and stable across processes.

Parameters:

compiler (_Compiler)

classmethod bind(composition, schema, ctx)

Compile composition against schema/ctx (frozen interface).

Parameters:
Return type:

ExecutionPlan

property plan_hash: str

Stable content hash of the symbolic plan (frozen interface).

property signatures: tuple[str, ...]

Labels of the distinct step signatures (one op-list variant each).

property schema: StateSchema

The bind-time state schema (input to bind()).

describe()

Canonical plan serialization (the plan_hash preimage).

Return type:

str

run_step(vault, step_index, *, on_segment=None)

Execute one step’s pre-bound op list (frozen interface, SPEC S05).

step_index must advance sequentially from 0 (modulo the signature period): the ping-pong swap state of the vault is phase-dependent. Materializes against vault on first use.

on_segment (S14, additive keyword with default) is the host-step seam: a callback invoked with every SegmentMarker the interpreter reaches (see icon_sc.core.plan.interpreter); monitors and time advancement live there, outside the plan.

Raises:

StalePlanError – After any out-of-band façade mutation (materialization against vault detects a changed epoch).

Parameters:
Return type:

None

class icon_sc.core.plan.bind.PlanBuilder(*args, **kwargs)

Double-dispatch surface of the plan compiler (PLAN S05 item 3).

Every S03/S04 container implements visit(plan_builder) forwarding to exactly one of these hooks; the same walk is reused by the post-slice halo validator.

The execution-plan op algebra (architecture §8.2, SPEC S05).

Exactly six op types — BoundCall, Swap, Axpy, DiffScale, CadenceMask, SegmentMarker — and nothing else. Every op is a flat NamedTuple with pre-bound references: buffers, callables and scalars are resolved when the plan is materialized against a vault, so interpreting an op performs no name lookups, no allocation and no negotiation.

The docstrings below are normative: the T2/T3 emitters (post-slice) treat them as the definition of each op’s semantics, and the T0≡T1 bitwise-equivalence contract of SPEC S05 is stated against them. In particular, every arithmetic op is specified as an exact sequence of numpy ufunc applications; emitters must preserve that sequence (per-element evaluation order), because reordering changes floating-point results (AGENTS.md: no reduction-order changes).

Semantics donor: tasmania’s DataArrayDictOperator kernel set (iadd / iaddsub / fma / fused sts_*_0 stage kernels, see REFERENCES.lock) — Axpy is the k-ary assign-or-accumulate generalization of the first three and DiffScale is the (ψ_prv - ψⁿ)/Δt forcing tasmania fuses into its first-stage kernels.

class icon_sc.core.plan.ops.Axpy(y, init, terms, scratch, divisor, tag)

Fused k-ary axpy: y ← (init + Σᵢ aᵢ·xᵢ) / divisor, exact ufunc sequence.

Normative evaluation order (numpy ufuncs, all with out=):

  1. If init is not None (assign form): np.multiply(x₀, a₀, out=y) where init = (a₀, x₀). a₀ = 1.0 is an exact copy (IEEE-754 multiplication by one is the identity). If init is None (accumulate form), y’s current content is the seed.

  2. For each (aᵢ, xᵢ) in terms, in order: np.multiply(xᵢ, aᵢ, out=scratch) then np.add(y, scratch, out=y).

  3. If divisor != 1.0: np.divide(y, divisor, out=y).

This reproduces bit-for-bit the T0 dict arithmetic it compiles from — e.g. phi + dt*k1 (tendency-stepper stages), acc += 1.0*x (dict_axpy), 0.75*phi + 0.25*(phi1 + dt*k2) (ssprk3) and (phi + 2.0*(phi2 + dt*k3))/3.0 (ssprk3 final; the trailing division is why divisor exists — x/3.0 and x*(1/3) differ in the last ulp). y may alias x₀ and may appear in terms only when the aliased read happens before the first write to y under the sequence above (the compiler guarantees this; step 1 with x₀ is y is safe, later aliased terms are not emitted). scratch is a plan-owned buffer of y’s shape/dtype; it carries no state between ops.

Parameters:
y: Any

Alias for field number 0

init: tuple[float, Any] | None

Alias for field number 1

terms: tuple[tuple[float, Any], ...]

Alias for field number 2

scratch: Any

Alias for field number 3

divisor: float

Alias for field number 4

tag: str

Alias for field number 5

class icon_sc.core.plan.ops.BoundCall(fn, args, tag)

One pre-specialized component-kernel invocation: fn(*args).

fn is the component’s raw kernel entry (the bound array_call / stage_array_call / substep_array_call method, resolved once at materialization), args the frozen argument pack. For the array_call ABI the pack is (inputs, outputs, timestep) where inputs/outputs are dicts built once at bind time with contract field names mapping to pre-resolved raw buffers; buffer identity is stable across steps (vault contract), so the same pack is valid for the plan’s lifetime. The interpreter performs exactly one Python call per BoundCall and never inspects the pack.

Parameters:
fn: Callable[[...], None]

Alias for field number 0

args: tuple[Any, ...]

Alias for field number 1

tag: str

Alias for field number 2

class icon_sc.core.plan.ops.CadenceMask(period, phase, ops, tag)

Cadence guard: run ops iff step_index % period == phase.

The compiler resolves every CadenceMask into the per-signature op lists (one flattened list per distinct step signature, architecture §8.2), so the interpreter’s hot path never evaluates the guard; the op type exists so a symbolic plan remains a single legible list and so debug tooling can interpret an unexpanded plan. When interpreted directly, the guard is one integer modulo — the firing rule of a fresh CallingFrequency wrapper under the S03 rounding-to-multiple rule (fires at step indices phase (mod period); see REFERENCES.lock, sympl UpdateFrequencyWrapper).

Parameters:
period: int

Alias for field number 0

phase: int

Alias for field number 1

ops: tuple[Any, ...]

Alias for field number 2

tag: str

Alias for field number 3

class icon_sc.core.plan.ops.DiffScale(y, minuend, subtrahend, divisor, tag)

Provisional-tendency forcing: y ← (minuend - subtrahend) / divisor.

Normative sequence: np.subtract(minuend, subtrahend, out=y) then np.divide(y, divisor, out=y). This is thesis eq. (2.11b)’s constant forcing (ψ_prv - ψⁿ)/Δt feeding every SequentialTendencyStepper evaluation (S04 computes the same two-op sequence per field). y must alias neither input.

Parameters:
y: Any

Alias for field number 0

minuend: Any

Alias for field number 1

subtrahend: Any

Alias for field number 2

divisor: float

Alias for field number 3

tag: str

Alias for field number 4

class icon_sc.core.plan.ops.SegmentMarker(kind, tag)

Boundary of an exchange-free plan segment (architecture §8.3).

A runtime no-op at T1. T2 graph capture and the T3 native driver split the op list at these markers: everything between two consecutive markers is capturable as one graph / emittable as one native block. kind labels the boundary reason ("step_end" in S05; halo exchanges, bridge calls and framework seams add kinds post-slice).

Parameters:
kind: str

Alias for field number 0

tag: str

Alias for field number 1

class icon_sc.core.plan.ops.Swap(vault, slot, alt_store, alt_index, tag)

Exchange a vault slot’s buffer with a plan-held alternate buffer.

vault.buffers[slot] and alt_store[alt_index] exchange contents and the vault’s generation counter is bumped (vault.note_swap()) so the lazy façade rebuilds its DataArray view of the swapped slot on next access. Swaps carry no data movement — they retarget which buffer the façade exposes for a ping-pong field after a step whose kernels wrote the alternate buffer. Ops never read through the vault at runtime (references are pre-bound per even/odd variant, architecture §8.2), so a Swap only keeps the vault’s public view coherent.

Parameters:
vault: Any

Alias for field number 0

slot: int

Alias for field number 1

alt_store: list[Any]

Alias for field number 2

alt_index: int

Alias for field number 3

tag: str

Alias for field number 4

The T1 plan interpreter (architecture §8.3, SPEC S05).

A boring, measurable for loop over pre-bound ops with a match on the op type — per-op cost is one Python dispatch plus the op’s kernel calls. No dict lookups on state names, no xarray, no contract logic, no per-step allocation beyond CPython transients (iterators, boxed scalars) that are freed within the step (the SPEC’s tracemalloc criterion: traced memory is step-invariant after warmup).

Every arithmetic op executes the exact ufunc sequence its icon_sc.core.plan.ops docstring declares (normative; bitwise T0≡T1).

The host-step seam (S14). run_ops(..., on_segment=...) yields to a host callback at every SegmentMarker — the minimal seam the T2 graph-replay tier needs: under stream capture each exchange-free segment becomes one captured graph, and the interpreter’s per-marker yield is exactly where T2 stops replaying and returns control to Python (monitors, time advancement, MPI, bridge calls — everything excluded from the plan). At T1 the callback costs one is not None check per marker; ctx.timeloop uses it to run monitors against the vault façade in the step_end host step (design note only — no T2 code in the slice).

icon_sc.core.plan.interpreter.run_ops(ops, step_index, on_segment=None)

Interpret one pre-bound op list (one step signature) at step_index.

on_segment (S14, additive) is invoked with every SegmentMarker reached — the host-step seam described in the module docstring.

Parameters:
Return type:

None

Plan staleness guards and debug renegotiation (architecture §8.2, SPEC S05).

A plan is valid for one (composition, schema, ctx) triple, enforced by:

  • the vault schema_hash + epoch recorded at materialization — any out-of-band mutation through the façade (field rebind/delete) bumps the epoch and the next run_step raises StalePlanError instead of silently binding dead buffers;

  • plan_hash — a stable content hash over the canonical serialization of the symbolic op lists (names and slots, never object ids), the schema, and the context configuration; identical inputs hash identically across processes;

  • debug renegotiation — renegotiate_and_diff() re-runs the full bind against the live composition every N steps and diffs the result against the bound plan, raising PlanDriftError on any divergence (ctx.timeloop(..., debug_renegotiate_every=N) wires it into the loop).

exception icon_sc.core.plan.guards.PlanCompileError

The composition cannot be compiled to an execution plan (bind-time).

exception icon_sc.core.plan.guards.PlanDriftError

Debug renegotiation produced a plan differing from the bound one.

exception icon_sc.core.plan.guards.StalePlanError

The vault mutated out of band since the plan was bound (§8.2 guard).

icon_sc.core.plan.guards.renegotiate_and_diff(plan, composition, ctx)

Re-run the full negotiation and diff against plan (debug builds, §8.2).

Binds the live composition afresh against the plan’s schema and context and compares plan hashes and the canonical op serialization. Returns silently when the plans agree.

Raises:

PlanDriftError – Naming the first divergence between the re-bound plan and plan.

Parameters:
Return type:

None

icon_sc.core.plan.guards.schema_fingerprint(schema)

Stable content hash of a StateSchema (sorted canonical text).

Parameters:

schema (StateSchema)

Return type:

str