Runtime Programs

Copy Markdown

Most 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:

KeyRequiredNotes
nameyesAtom name the field is addressed by
typeyes"string", "integer", "float", "boolean", "atom", "any"
kindyes"input" or "output"
descnoFree-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

FieldTypeNotes
idstringStable identifier you choose
inputslist of fieldsProgram-level inputs
outputslist of fieldsProgram-level outputs
nodeslist of nodesPredictor invocations
edgeslist of edgesHow data flows
metadatamap (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 shapeMeaning
["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 to nil and are flagged degraded.

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 valueResult on skipped outputs
:raise (default)raises Dsxir.Errors.Runtime.SkippedOutputs
:tagged_tuplereturns {prog, {:partial, %Prediction{skipped: [:field, ...]}}}
nilreturns 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)
end

If :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)
end

Errors 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.