OrchidIntervention
Inject, override, and short-circuit step outputs in Orchid DAGs — without touching the graph structure.
Why
Orchid executes DAGs where steps are wired by data keys. Sometimes you need to:
- Replace a step's output with a known value (e.g., cached result, test fixture)
- Provide inputs from a source other than the caller's argument list
- Skip expensive steps entirely when their outputs are already known
Rewiring the DAG for each scenario is tedious and error-prone. Interventions let you declaratively alter execution results from the outside.
Concepts
Intervention Map
A map from IO keys to intervention specs:
%{
"beans" => {:input, Param.new("beans", :raw, 20)},
"powder" => {:override, Param.new("powder", :solid, 25)}
}
Each spec is a {type, payload} tuple where payload can be a Param.t(), a zero-arity function, or any term that resolves to a Param.
Intervention Types
| Type | Behaviour | Short-circuit |
|---|---|---|
:input | Injects as initial param (via Operon) | N/A |
:override | Replaces step output entirely | ✅ |
MyModule |
Custom merge logic (implement OrchidIntervention.Operate) | Configurable |
Short-circuit
When every output key of a step is covered by a short-circuit-capable intervention, the step body is never executed. This is how you skip expensive computations.
Quick Start
Install
# mix.exs
[
{:orchid, "~> 0.6"},
{:orchid_intervention, "~> 0.1"}
]Example
A coffee workflow with two steps:
defmodule Barista.Grind do
use Orchid.Step
alias Orchid.Param
def run(beans, opts) do
amount = Param.get_payload(beans)
IO.puts("Grinding #{amount}g beans...")
{:ok, Param.new("powder", :solid, amount * Keyword.get(opts, :ratio, 1))}
end
end
defmodule Barista.Brew do
use Orchid.Step
alias Orchid.Param
def run([powder, water], opts) do
style = Keyword.get(opts, :style, :espresso)
p_amount = Param.get_payload(powder)
w_amount = Param.get_payload(water)
IO.puts("Brewing #{style} with #{p_amount}g powder and #{w_amount}ml water...")
{:ok, Param.new("coffee", :liquid, "Cup of #{style} with #{w_amount}ml")}
end
end
steps = [
{Barista.Brew, ["powder", "water"], "coffee", [style: :latte]},
{Barista.Grind, "beans", "powder"}
]
Now apply interventions — override powder to skip grinding, inject beans and water as inputs:
interventions = %{
"beans" => {:input, Param.new("beans", :raw, 20)},
"water" => {:input, Param.new("water", :raw, 200)},
"powder" => {:override, Param.new("powder", :solid, 25)}
}
{:ok, results} = Orchid.run(
steps,
[], # no explicit inputs needed
operons_stack: [Orchid.Operon.ApplyInputs],
global_hooks_stack: [Orchid.Hook.ApplyInterventions],
baggage: %{interventions: interventions}
)
# => Brewing latte with 25g powder and 200ml water...
# *Step Grind were short-circuitedGrind never ran — its output key "powder" was fully covered by an :override intervention.
API Summary
| Component | Role |
|---|---|
Orchid.Hook.ApplyInterventions | Global hook — applies non-input interventions to step outputs |
Orchid.Operon.ApplyInputs |
Operon — injects :input interventions as initial params |
OrchidIntervention.Operate | Behaviour for custom intervention merge logic |
OrchidIntervention.Operate.Override | Built-in: replace output, supports short-circuit |
OrchidIntervention.Resolver |
Resolves thunks and hydrates {:ref, ...} payloads |
OrchidIntervention.KeyBuilder | Deterministic cache key derivation for interventions |
OrchidIntervention.Storage | Two-tier cache (intervention data + merge results) |
Custom Operate
Implement the behaviour to define your own merge semantics:
defmodule MyApp.Operate.Offset do
@behaviour OrchidIntervention.Operate
@impl true
def short_circuit?, do: false
@impl true
def data_enable, do: {true, true} # both inner and intervention affect cache
@impl true
def merge(%Orchid.Param{payload: inner_data} = p, %Orchid.Param{payload: intervention_data}) do
result = Enum.zip(inner_data, intervention_data, &(&1 + &2))
{:ok, %{p | payload: result}}
end
end
# Usage
%{"signal" => {MyApp.Operate.Offset, Param.new("signal", :number, 10)}}