Runtime Programs
Copy MarkdownMost dsxir programs are written in Elixir with use Dsxir.Module and
shipped as part of your release. Runtime programs let you build a
program from data instead — a JSON-ish map you can author at
runtime, store in a database, fetch over the wire, or have an LLM
generate. The program structure becomes a payload, not code.
This guide walks through building, running, and optimizing one.
Your first runtime program
Describe the whole thing as a map — signature included:
payload = %{
"id" => "qa-v1",
"inputs" => [%{"name" => "question", "type" => "string"}],
"outputs" => [%{"name" => "answer", "type" => "string"}],
"nodes" => [
%{
"name" => "answer",
"impl" => "Elixir.Dsxir.Predictor.Predict",
"signature" => %{
"instruction" => "Answer the question concisely.",
"fields" => [
%{"name" => "question", "type" => "string", "kind" => "input"},
%{"name" => "answer", "type" => "string", "kind" => "output"}
]
}
}
],
"edges" => [
%{"from" => ["program_input", "question"], "to" => ["node", "answer", "question"]},
%{"from" => ["node", "answer", "answer"], "to" => ["program_output", "answer"]}
]
}Turn it into a %Dsxir.Program{} and run it:
{:ok, rp} = Dsxir.RuntimeProgram.from_map(payload)
prog = Dsxir.Program.from_runtime(rp)
{_, prediction} = Dsxir.Program.forward(prog, %{question: "What is 2+2?"})
prediction.fields[:answer]
# => "4"That's the whole loop. No Elixir modules needed for the signature — the inline blob is the recommended form for runtime programs.
Signatures
Every predictor node needs a signature: the input fields the LLM should expect, the output fields it must produce, and an optional natural-language instruction. For runtime programs, the inline form is the default. Author it as data inside the node:
%{
"name" => "draft",
"impl" => "Elixir.Dsxir.Predictor.Predict",
"signature" => %{
"instruction" => "Draft an essay on the given topic.",
"fields" => [
%{"name" => "topic", "type" => "string", "kind" => "input",
"desc" => "The subject of the essay."},
%{"name" => "essay", "type" => "string", "kind" => "output",
"desc" => "Two to three paragraphs."}
]
}
}A field has:
| Key | Required | Notes |
|---|---|---|
name | yes | Atom name the field is addressed by |
type | yes | "string", "integer", "float", "boolean", "atom", "any" |
kind | yes | "input" or "output" |
desc | no | Free-form description shown to the LLM |
instruction is optional. Omit it if the field descriptions are
enough; supply it when the model needs a sentence or two of guidance.
If you'd rather reuse a use Dsxir.Signature module you already have
compiled (e.g. shared across many programs, or generated by macros),
the signature field also accepts a module string:
%{
"name" => "answer",
"impl" => "Elixir.Dsxir.Predictor.Predict",
"signature" => "Elixir.MyApp.AnswerQuestion"
}Use whichever fits — inline keeps the program self-contained and lets you author the whole thing as data; module references are useful when the same signature appears in many programs and you want one source of truth.
The payload schema
| Field | Type | Notes |
|---|---|---|
id | string | Stable identifier you choose |
inputs | list of fields | Program-level inputs |
outputs | list of fields | Program-level outputs |
nodes | list of nodes | Predictor invocations |
edges | list of edges | How data flows |
metadata | map (optional) | Free-form; description is excluded from the hash |
A field has name and type (and an optional description).
Types may be "string", "integer", "float", "boolean", "atom",
or "any".
A node has:
name— atom the rest of the program refers to.impl— module string for a predictor, e.g."Elixir.Dsxir.Predictor.Predict","Elixir.Dsxir.Predictor.ChainOfThought".signature— an inline map (see "Signatures" above) or, if you prefer to reuse a compiled module, a string like"Elixir.MyApp.AnswerQuestion".guard_source— optional, a predicate expression (see below).
An edge has from, to, and an optional kind ("required" or
"optional"; defaults to "required"). Endpoints are tagged lists:
| Endpoint shape | Meaning |
|---|---|
["program_input", "field"] | a program-level input field |
["program_output", "field"] | a program-level output field |
["node", "name", "field"] | a field on another node |
["const", value] | a literal constant (from only) |
Node names, field names, impl modules, and signature modules are
resolved via String.to_existing_atom/1 — they must already be loaded
when you call from_map/2. This is intentional, and it's what makes
loading untrusted payloads safe.
Multi-step programs
Wire nodes together by pointing one node's edge at another:
payload = %{
"id" => "draft-then-refine",
"inputs" => [%{"name" => "topic", "type" => "string"}],
"outputs" => [%{"name" => "essay", "type" => "string"}],
"nodes" => [
%{
"name" => "draft",
"impl" => "Elixir.Dsxir.Predictor.Predict",
"signature" => %{
"instruction" => "Draft an essay on the topic.",
"fields" => [
%{"name" => "topic", "type" => "string", "kind" => "input"},
%{"name" => "essay", "type" => "string", "kind" => "output"}
]
}
},
%{
"name" => "refine",
"impl" => "Elixir.Dsxir.Predictor.Predict",
"signature" => %{
"instruction" => "Tighten the prose; keep the meaning.",
"fields" => [
%{"name" => "essay", "type" => "string", "kind" => "input"},
%{"name" => "essay", "type" => "string", "kind" => "output"}
]
}
}
],
"edges" => [
%{"from" => ["program_input", "topic"], "to" => ["node", "draft", "topic"]},
%{"from" => ["node", "draft", "essay"], "to" => ["node", "refine", "essay"]},
%{"from" => ["node", "refine", "essay"], "to" => ["program_output", "essay"]}
]
}The graph must be acyclic. dsxir walks it in topological order.
Conditional nodes (guards)
A node can declare a guard_source — a small predicate expression that
decides at runtime whether the node should run. Guards may reference
program inputs (input.field) and outputs of upstream nodes
(node_name.field).
%{
"name" => "translate",
"impl" => "Elixir.Dsxir.Predictor.Predict",
"signature" => %{
"instruction" => "Translate the answer into the requested locale.",
"fields" => [
%{"name" => "answer", "type" => "string", "kind" => "input"},
%{"name" => "locale", "type" => "string", "kind" => "input"},
%{"name" => "answer", "type" => "string", "kind" => "output"}
]
},
"guard_source" => "input.locale != \"en\""
}The expression DSL supports:
- Literals: numbers, strings, atoms (
:foo), booleans,nil. - Field paths:
node.field,input.field. length(field)over strings and lists.- Comparison:
==,!=,<,<=,>,>=,in,not in. - Boolean composition:
and,or,not, parentheses. - Literal arithmetic on the right-hand side:
length(x.notes) > 3 + 2.
It does not support function calls, string operations, or any kind
of evaluation. Guards are validated at construction time — a malformed
guard makes from_map/2 return {:error, %Invalid.RuntimeProgram{}}
with a per-error path so you can show good messages to whoever authored
the program.
When a guard evaluates to false, the node is skipped.
Required vs optional edges
A skipped node propagates through the graph by its edge kind:
kind: "required"— downstream consumers are also skipped.kind: "optional"— downstream consumers run with the field bound toniland are flaggeddegraded.
A diamond with an optional fan-in:
%{
"name" => "extra_context",
"impl" => "Elixir.Dsxir.Predictor.Predict",
"signature" => %{
"instruction" => "Look up extra context for the question.",
"fields" => [
%{"name" => "question", "type" => "string", "kind" => "input"},
%{"name" => "context", "type" => "string", "kind" => "output"}
]
},
"guard_source" => "input.use_context == true"
}%{"from" => ["node", "extra_context", "context"],
"to" => ["node", "answer", "context"],
"kind" => "optional"}If extra_context is skipped, answer still runs — with context: nil — and the optimizer can later exclude these degraded runs from
its demo pool (see "Optimizing" below).
What happens to program outputs when nodes are skipped?
If a program output's chain was skipped (via a :required cascade or a
false guard on the producing node), you control the result with the
on_skip opt on Dsxir.Program.forward/3:
on_skip value | Result on skipped outputs |
|---|---|
:raise (default) | raises Dsxir.Errors.Runtime.SkippedOutputs |
:tagged_tuple | returns {prog, {:partial, %Prediction{skipped: [:field, ...]}}} |
nil | returns a %Prediction{} with the skipped fields set to nil |
case Dsxir.Program.forward(prog, %{question: "..."}, on_skip: :tagged_tuple) do
{_, %Dsxir.Prediction{} = ok} -> use_full_prediction(ok)
{_, {:partial, %Dsxir.Prediction{} = p}} -> handle_partial(p, p.skipped)
endIf :skipped is nil, every output was produced. Otherwise it's the
list of output field names whose upstream chain didn't run.
Validation errors
from_map/2 returns {:error, %Dsxir.Errors.Invalid.RuntimeProgram{errors: [...]}} when the payload is structurally valid but semantically broken.
Each error has a path, a code, a message, and an optional
suggestion:
case Dsxir.RuntimeProgram.from_map(payload) do
{:ok, rp} ->
Dsxir.Program.from_runtime(rp)
{:error, %Dsxir.Errors.Invalid.RuntimeProgram{errors: errors}} ->
Enum.each(errors, fn e ->
IO.puts("#{Enum.join(e.path, "/")}: [#{e.code}] #{e.message}")
end)
endErrors accumulate across the validator's phases, so one bad payload gives you the full picture, not just the first failure.
Persistence
A program has a content-addressable version (a 32-byte SHA-256
computed from its structure). Pass a store to from_map/2 and dsxir
will persist the validated program for you:
# Start a store under your supervision tree
children = [
{Dsxir.RuntimeProgram.Store.ETS,
name: MyApp.RPStore, table: :my_runtime_programs}
]
# Persist on construction
{:ok, rp} =
Dsxir.RuntimeProgram.from_map(payload,
store: {Dsxir.RuntimeProgram.Store.ETS, :my_runtime_programs}
)
# Look up later
{:ok, ^rp} =
Dsxir.RuntimeProgram.Store.ETS.get(:my_runtime_programs, {rp.id, rp.version})Dsxir.RuntimeProgram.Store.File ships for file-backed persistence; the
Dsxir.RuntimeProgram.Store behaviour is the seam to plug your own
backend (Postgres, S3, etc.). The reference stores serialize via the
v2 artifact format, the same one
Dsxir.Artifact.save!/load! uses for compiled programs.
A loaded program is re-validated automatically — tampered payloads fail with a clear error rather than silently surfacing later.
Optimizing
Runtime programs go through the optimizers exactly like compiled programs:
{:ok, compiled, _stats} =
Dsxir.compile(Dsxir.Optimizer.BootstrapFewShot,
Dsxir.Program.from_runtime(rp),
trainset,
&my_metric/3,
max_bootstrapped_demos: 4
)
Dsxir.Artifact.save!(compiled, "/tmp/qa.dsxir.json")BootstrapFewShot honors a degraded_demos: option (:exclude by
default) so demos collected from runs that took a degraded path don't
poison the pool. LabeledFewShot, KNNFewShot, and MIPROv2 work the
same way — the runtime/compiled distinction is invisible to them.
Custom policy with program plugs
If you want to enforce tenant policy, quotas, allowlists, or anything
else before a runtime program is accepted, register one or more
program_plugs. Each plug is a 1-arity function that receives a
%Dsxir.ProgramContext{} and returns :ok or {:halt, reason}. The
first halt stops construction and raises
Dsxir.Errors.Halted.ProgramPlug.
allow_known_tenants = fn %{metadata: meta} ->
if meta[:tenant_id] in MyApp.known_tenants(),
do: :ok,
else: {:halt, :unknown_tenant}
end
Dsxir.Settings.context([program_plugs: [allow_known_tenants]], fn ->
Dsxir.RuntimeProgram.from_map(payload, caller: current_user_id)
end)Plugs run during construction only; they do not run again when a saved program is loaded.
When to reach for a runtime program
Pick a runtime program when the structure of a program needs to vary independently of a deploy: tenant-authored flows, A/B experiments over wiring, programs emitted by another LLM, generated optimizations applied at request time, programs stored in a database keyed by version.
Stick with use Dsxir.Module for the default case: a single,
code-versioned program that ships with your app. Compiled programs let
you write arbitrary forward/2 logic, use helpers, and incur zero
per-request validation.
The two interoperate. The optimizer, the trace, the artifact format,
and Dsxir.Program.forward/3 all treat both shapes the same way.