Skip to content

Definitions & params

All conditions and ::set right-hand sides in Lute are CEL expressions. Rather than repeat a condition everywhere it is needed, you name it once as a def and reference it as @name. Defs are the language’s reusable-value layer (distinct from schema reuse via imports and content reuse via components).

defs declares named, typed CEL values, either inline in a scene’s frontmatter or in an imported schema. Each entry has a type, an optional params block, and a cel body:

defs:
warm: { type: bool, cel: "scene.affect.elena >= 2" }
tight: { type: string, cel: "'closeUp'" }
fond: { type: bool, cel: "scene.affect.marina >= 1" }

A def is referenced with @name. Because @ is a compile-time macro, the reference is expanded to its inline CEL before evaluation — a def is not a runtime function call, just a named piece of CEL. A bool def reads as a guard; a def that folds to a constant reads as a staging value:

<when test="@fond">
@fixer{mono}: I asked nicely.
</when>
::camera{framing=@tight}

A @ref must appear in a position whose required type matches the def’s declared type, and its name must be declared in defs. Def names and param names are identifiers (a letter or _, then letters, digits or _): a condition reads them bare, like a JavaScript variable, so lamp-lit is E-PATH-IDENT naming lampLit.

In a directive attribute the ref is bare — framing=@tight. A quoted framing="@tight" is the literal string @tight, which the framing domain rejects as E-BAD-ENUM. An attribute value is a constant, not an expression, so the compiler writes the literal the def folds to ("framing": "closeUp") and checks it like an authored one (E-BAD-ENUM, E-ATTR-TYPE). A def that reads state does not fold, and using it there is E-ATTR-DEF-DYNAMIC. Branch instead, with a literal in each arm:

<match subject="scene.affect.elena">
<when is="5..">
::camera{framing="closeUp"}
</when>
<otherwise>
::camera{framing="wide"}
</otherwise>
</match>

That advice is for a directive attribute. A component enum param takes the def directly, even one that reads state: ::use{component="greet" tier=@mood} hands the def to the component, whose <match subject="@tier"> dispatches on it at run time, so there is nothing to branch around (see Reusable content components). Only a component that puts the param into a directive attribute of its own meets E-ATTR-DEF-DYNAMIC again, reported at the ::use.

In content text, {{@name}} renders the def’s value: the artifact carries the def body with the placeholder, and the engine evaluates it like any guard. A def whose body cannot be inlined into one expression (an expansion cycle, or a body that reads $) is E-INTERP-DEF. A def body gets the same CEL profile check as any guard, so % or size() in one is E-CEL-PROFILE at the def’s own key.

A def may declare typed params, turning it into a parameterized macro invoked as @name(args). The arguments are bound to the params at expansion time:

defs:
atLeast: { type: bool, params: { n: int }, cel: "user.level >= n" }
chose: { type: bool, params: { q: choiceRef, opt: choiceId }, cel: "scene.choices[q] == opt" }

(From docs/examples/showcase/schema/base.schema.yaml.)

<match subject="scene.affect.marina">
<when test="@atLeast(3)">
@fixer{mono}: A veteran's welcome.
</when>
<otherwise>
@fixer{mono}: Early days yet.
</otherwise>
</match>

(Call site from docs/examples/showcase/episode01.lute; a minimal parameterized def is in docs/examples/param-def.lute.)

An argument is spliced into the body as a unit. An atomic argument — a path, an integer or fractional literal, a plain string literal, or an already parenthesized group — goes in bare, so @atLeast(3) expands to (user.level >= 3). Anything else keeps parentheses, so @atLeast(user.level + 1) expands to (user.level >= (user.level + 1)) and the argument can never regroup with the body’s operators. (Before 0.24.0 every argument was parenthesized; the compiled expression is the same, only the IR’s raw text is shorter.)

lute trace and lute beats print a def reference as you wrote it: a <match subject="@today"> header reads <match @today>, an arm or choice guard (@atLeast(3)), a line guard guard `@atLeast(3)`, the coverage summary names the def, and a beat’s when column reads @runsAtLeast(2) && @firstDay. Pass --expand to see the inlined CEL instead. For a scene with the gated lines @narrator{when="@atLeast(3)"}: … and @narrator{when="@atLeast(user.level + 1)"}: …:

Terminal window
$ lute trace board.lute
trace: board.lute (seeds: 0 paths, 0 facts; 0 selections)
## Board
guard `@atLeast(3)`: skipped
guard `@atLeast(user.level + 1)`: skipped
trace complete: 2 decisions; guard `@atLeast(3)` @13:1: skipped, guard `@atLeast(user.level + 1)` @14:1: skipped
$ lute trace board.lute --expand
trace: board.lute (seeds: 0 paths, 0 facts; 0 selections)
## Board
guard `(user.level >= 3)`: skipped
guard `(user.level >= (user.level + 1))`: skipped
trace complete: 2 decisions; guard `(user.level >= 3)` @13:1: skipped, guard `(user.level >= (user.level + 1))` @14:1: skipped

--json keeps the expansion in its id/guard/label/when fields and adds the authored text beside it (authoredGuard and friends in a trace, whenAuthored in lute beats). See Tracing.

A def whose CEL reads run/user/app state belongs in the shared schema document, so it can be imported by every scene that needs it (and refined via extends:). A def that reads only scene.* can live inline in the scene. Duplicate def names across imports are a static error — no silent shadowing.

A rule’s scalar guard may use a def like any condition: lit(lamp) :- cel("@firstDay"), or with arguments, cel("@dayAtLeast(3)"). The guard is expanded against the project’s defs before it is checked and evaluated, and the expanded body still has to pass the rule-guard firewall (scalar state only, no fact query). A def that does not exist, a wrong argument count, or a $ in a rule guard is E-RULE-GUARD-DEF:

---
kind: scene
id: shed.lamp
title: The lamp
state:
run.day: { type: int, default: 1 }
defs:
firstDay: { type: bool, cel: "run.day == 1" }
entities:
item: { members: [lamp, rope] }
relations:
lit: { args: [item], derive: true }
rules:
- "lit(lamp) :- cel(\"@firstDy\")"
---
## Lamp
@narrator{when="holds('lit', ['lamp'])"}: The lamp is lit.

Before 0.24.0 such a rule passed check and then silently derived nothing, because the guard was evaluated unexpanded. See Facts & Datalog for rules and their guards.

The static checker owns five @ref checks, each conservative (only provably-wrong cases flag):

  • the name is declared in defs — else E-UNDECLARED-REF;
  • the call arity matches the declared params — else E-REF-ARITY;
  • each statically-resolvable argument matches its param’s type — else E-REF-ARG-TYPE;
  • a whole-slot @ref produces the position’s required type — else E-REF-TYPE;
  • every state path the def body reads is safe to read where the def is used — else E-MAYBE-UNSET, naming the path and the def.

The last check runs at every use: {{@lastF}}, a when="@lastF > 30", a ::use argument fathoms=@lastF, a ::set right-hand side, and a <match subject="@lastF"> subject all read what the def’s body reads. A def over a path with no default is therefore guarded where it is used, or guards itself:

---
kind: scene
id: dock.recap
title: Last time
state:
run.depth: { type: int, default: 0 }
defs:
lastF: { type: int, cel: "prev.run.depth * 10" }
deepLast: { type: bool, cel: "has(prev.run.depth) && prev.run.depth > 3" }
---
## Recap
@narrator{when="has(prev.run.depth)"}: Last run you reached {{@lastF}} feet.
@narrator{when="@deepLast"}: Deeper than most.
@narrator: That was {{@lastF}} feet.

The first two lines are clean: the line’s guard proves prev.run.depth, and @deepLast carries its own has guard. The third reads prev.run.depth through @lastF with nothing proving it set:

recap.lute:16:21: error [E-MAYBE-UNSET] state path `prev.run.depth` may be read before it is set, read through `@lastF` (no default, no dominating `::set`, no guard)

The min/max/values def fields from earlier drafts are removed: a def is fully described by its type, optional params, and cel.