Deferred Execution¶
Some values aren't available when the config loads. A runtime ID, a trained model, a database connection pool. Dracon gives you several ways to defer evaluation until the information exists.
If what you want is a clean declarative runtime boundary, start with Runtime Contracts. This guide covers the underlying mechanisms.
When to use what¶
| Situation | Tool | Why |
|---|---|---|
| All info is available at composition time, you just need forward references | !define x: !Type (lazy) |
Construction is deferred, but everything is known |
| Need runtime context not available during composition | !deferred |
Pauses the entire composition subtree |
| Single Pydantic field needing late binding | Resolvable[T] |
Field-level pause; resolve when ready |
Model with ${...} defaults that depend on not-yet-available context |
LazyDraconModel |
Defers interpolation to attribute access time |
Pick the lightest tool that fits. !define handles most cases. Reach for !deferred only when you truly need runtime injection.
!deferred tag¶
Basic usage¶
Mark a subtree as deferred, and it comes out as a DeferredNode instead of a constructed object:
When you load this, output is a DeferredNode. The ${run_id} expression is not evaluated yet.
One-step construction¶
The simplest way to resolve a deferred node: call .construct() with a context dict.
import dracon
config = dracon.loads("""
output: !deferred "/tmp/${run_id}/results"
""")
# later, when run_id is known:
path = config['output'].construct(context={'run_id': 'abc-123'})
# path == "/tmp/abc-123/results"
Two-step: compose then construct¶
If you want to inspect the composed tree before constructing, split the process:
from dracon import compose, construct
node = config['output']
# step 1: compose with partial context
composed = compose(node, context={'run_id': 'abc-123'})
# composed is a CompositionResult; you can inspect it
# step 2: construct from the composed result
result = construct(composed)
Type hints¶
Attach a type to the deferred node so the constructor knows what to build:
This tells Dracon to construct the resolved subtree as MyModel.
Extended syntax¶
The !deferred tag supports query-parameter-style options after a :: separator:
# drop all inherited context before construction
clean: !deferred::clear_ctx=True
key: "${injected_value}"
# reroot @path references so they're relative to this subtree
local: !deferred::reroot=true
ref: "${@/sibling}"
You can combine a type hint with options:
The format is !deferred::[options]:[TypeName].
If runtime chooses the constructor, alias it first¶
Sometimes the deferred branch does not just need runtime values. It needs runtime logic to choose what to build.
The cleanest idiom is usually:
- compute the constructor with
!define - give it a short local name
- use that name as a normal tag
decision: !deferred
!define Action: ${llm_decide(prompt='triage', metrics=jobs.meta(group='trials'))}
!Action {}
This is usually clearer than trying to inline the whole choice into a dynamic tag. It also avoids awkward YAML tag syntax once the expression gets long.
Runtime contracts as interface data¶
A DeferredNode implements the Symbol protocol. Its interface() method surfaces the contracts (!require, !assert) declared inside the deferred branch as structured InterfaceSpec data:
node = config['reporting'] # a DeferredNode
iface = node.interface()
# iface.kind == SymbolKind.DEFERRED
# iface.params -- the !require parameters
# iface.contracts -- the !assert contracts
This means you can inspect what a deferred section expects before calling .construct(). The same data drives:
- error messages when required runtime inputs are missing
- the
--symbolsCLI output - the
__scope__introspection API
Resolvable[T] for Pydantic fields¶
When you just need one field to stay unresolved, use Resolvable[T]. It works through the YAML tag, not the type annotation alone. Tag the YAML value with !Resolvable[T] to pause construction on that field:
from dracon import Resolvable
from pydantic import BaseModel
class Pipeline(BaseModel):
preprocessor: Resolvable[Preprocessor] # Pydantic accepts Resolvable here
batch_size: int = 32
The batch_size resolves immediately. The preprocessor stays as a Resolvable until you call .resolve():
config = dracon.load('pipeline.yaml', context={'Pipeline': Pipeline, 'Preprocessor': Preprocessor})
# later, when the tokenizer is available:
lazy = config.preprocessor.resolve(context={'tokenizer': my_tokenizer})
prep = lazy.resolve() # force any remaining lazy interpolations
Resolvable stores the raw YAML node and the constructor state. It is a snapshot of the construction process that you can resume later with extra context.
A Resolvable can be empty-checked with bool(resolvable) and copied with .copy().
For most cases, !deferred is simpler and more intuitive. Use Resolvable when you want the parent model fully constructed and validated, with only specific fields deferred.
LazyDraconModel¶
Subclass LazyDraconModel instead of BaseModel when your model has ${...} defaults that depend on context not yet available at construction time:
from dracon.lazy import LazyDraconModel
class Experiment(LazyDraconModel):
name: str
output_dir: str = "${base_dir}/${name}"
checkpoint: str = "${output_dir}/checkpoint.pt"
Field access triggers resolution. When you do exp.output_dir, Dracon evaluates the ${...} expression at that moment, using whatever context is available on the model.
exp = dracon.loads("""
!Experiment
name: "run-42"
""", context={'base_dir': '/data/experiments'})
print(exp.output_dir) # /data/experiments/run-42
print(exp.checkpoint) # /data/experiments/run-42/checkpoint.pt
How it works¶
- Fields with
${...}values are stored asLazyInterpolableobjects instead of being resolved at construction time. LazyDraconModel.__getattribute__intercepts attribute access and calls.resolve()on anyLazyInterpolableit finds.- The resolved value replaces the lazy object, so resolution only happens once per field.
With discriminated unions¶
If your model has a Literal discriminator field (for discriminated unions / subcommands), LazyDraconModel automatically excludes it from the lazy validator. No special handling needed on your end.
Permissive evaluation¶
Sometimes you have partial context and want to resolve what you can, leaving unknown ${...} expressions as strings for a later pass.
Basic permissive mode¶
from dracon import resolve_all_lazy
config = dracon.loads("""
greeting: "Hello ${name}, welcome to ${place}"
""")
# resolve with partial context:
result = resolve_all_lazy(config, permissive=True,
context_override={'name': 'Alice'})
# result['greeting'] == "Hello Alice, welcome to ${place}"
The ${name} part resolved; the ${place} part stayed as a string because place wasn't in context.
Two-phase resolution¶
This is useful when different parts of the context become available at different times:
# phase 1: resolve what we know
partial = resolve_all_lazy(config, permissive=True,
context_override={'name': 'Alice'})
# phase 2: finish up
final = resolve_all_lazy(partial, permissive=False,
context_override={'place': 'Wonderland'})
# final['greeting'] == "Hello Alice, welcome to Wonderland"
Where permissive is available¶
evaluate_expression(..., permissive=True)- the expression evaluatorLazyInterpolable(value, permissive=True)- individual lazy valuesresolve_all_lazy(obj, permissive=True)- recursive resolutiondracontainer.resolve_all_lazy(permissive=True)- on Dracontainer instances
Under the hood, permissive mode uses AST constant folding: it substitutes known variables into the expression, evaluates what it can, and returns the simplified expression string for anything that remains unresolved.