The Primitives¶
Dracon has a small set of building blocks. Each one does one thing. The interesting part is how they combine.
Primitive table¶
| Primitive | Phase | What it does |
|---|---|---|
!include |
Composition | Inject external YAML content into the tree |
<<: |
Composition | Merge nodes with strategy control |
!define |
Composition | Bind a name in scope (hard, not overridable) |
!set_default |
Composition | Bind a name in scope (soft, overridable) |
!require |
Composition | Declare a variable must exist |
!assert |
Composition | Enforce a boolean condition |
!if |
Composition | Conditional inclusion of nodes |
!each |
Composition | Iterate and generate nodes |
!fn |
Composition | Create a callable YAML template |
!pipe |
Composition | Chain callables sequentially |
!fn:path |
Construction | Partial-apply a Python function |
!deferred |
Construction | Pause a subtree for later construction |
${...} |
Resolution | Evaluate a Python expression lazily |
@path |
Resolution | Reference a value elsewhere in the tree |
&path |
Resolution | Copy a node from elsewhere in the tree |
Note
For the full syntax of each primitive, see the Instruction Tags and Interpolation reference pages.
The key insight¶
These primitives are orthogonal. !define does not know about !include. !each does not know about <<:. They compose because they all operate on the same thing: the node tree.
This means you can combine them freely. A few patterns show up over and over.
Combination examples¶
1. !define + !each + !if -- conditional sweep¶
Generate a list of experiments, but only include ones that meet a condition:
!define num_layers: 4
!define configs: [[64, 128], [128, 256], [256, 512]]
experiments:
!each(layer_sizes) ${configs}:
!if ${len(layer_sizes) <= num_layers}:
name: exp_${layer_sizes[0]}
layers: ${layer_sizes}
!define sets a variable. !each(var) ${iterable}: iterates over a list, binding each item to var. !if filters based on an expression that can reference both the !each variable and the !define variable.
2. !fn + !include + <<: -- parameterized templates¶
A vocabulary file defines a reusable template. Callers include it and override specific fields:
# model_template.yaml
!set_default hidden_size: 256
!set_default num_heads: 8
architecture:
hidden: ${hidden_size}
heads: ${num_heads}
ff_dim: ${hidden_size * 4}
# config.yaml
!define hidden_size: 512
!define num_heads: 16
<<: !include file:model_template.yaml
training:
lr: 0.001
The template uses !set_default for its parameters. The caller uses !define to override them. Because hard values beat soft values during merging, the caller's values win. See Context and Scope for the full priority story.
3. !define + !fn + !pipe -- processing pipeline¶
Define reusable transforms and chain them:
!define normalize: !fn
!require input
result: ${input.strip().lower()}
!fn result: ${result}
!define prefix: !fn
!require input
!set_default tag: "v"
!fn result: ${tag + "_" + input}
!define process: !pipe
- ${normalize}
- ${prefix}
output: ${process(input=" Hello World ", tag="prod")}
# -> "prod_hello world"
!fn creates callable templates. !pipe chains them so each stage's output feeds into the next. The whole thing is just YAML.
4. !deferred + ${...} + @path -- runtime cross-references¶
A config that adapts at runtime:
!deferred:
!require runtime_env
database:
host: ${runtime_env}_db.internal
pool_size: ${20 if runtime_env == "production" else 5}
cache:
backend: redis
host: ${@database.host} # reference the database host
The !deferred tag pauses composition until .construct(context={"runtime_env": "production"}) is called. At that point, ${...} expressions evaluate with the runtime context, and @path references resolve against the constructed tree.
The callable spectrum¶
Dracon has several ways to make things callable, from simple to complex:
| Mechanism | What it is |
|---|---|
${expr} |
Inline expression, evaluated in context |
!define x: value |
Named constant (not callable, but feeds into expressions) |
!fn (inline body) |
YAML template wrapped as a callable |
!fn:module.func |
Partial application of a Python function |
!pipe |
Chain of callables, output feeds forward |
!include |
Structural inclusion (not callable, but parameterizable via !set_default) |
The first three live entirely in YAML-land. !fn:path bridges to Python. !pipe composes any of them. !include is the coarsest tool, for pulling in whole config sections.
!fn vs !fn:path¶
These look similar but work differently:
!fncreates aDraconCallable. Each call deep-copies the template node, injects kwargs as context, and runs the full composition + construction pipeline. The template is YAML.!fn:pathcreates aDraconPartial. It resolvespathto a Python function and stores the provided kwargs. Each call merges runtime kwargs with stored kwargs and calls the function directly. No YAML involved at call time.
# YAML template callable
!define greet: !fn
!require name
message: Hello, ${name}!
!fn result: ${message}
# Python function partial
!define tokenize: !fn:transformers.AutoTokenizer.from_pretrained
pretrained_model_name_or_path: bert-base-uncased
How primitives interact with phases¶
One thing worth internalizing: composition primitives run before construction primitives, always. This means:
!definevalues are available to!ifand!eachconditions during composition- But they are also available to
${...}expressions during construction/resolution !fntemplates are created during composition, but their bodies are composed + constructed on each call!deferrednodes are identified during composition, but their contents are untouched until.construct()is called at runtime
The phase boundary is the CompositionResult. Everything before it is tree manipulation. Everything after it is object creation. This is covered in detail in The Three Phases.