Facts and Datalog
Scalar tiers hold magnitudes; they cannot express relationships between entities — “Shadowheart is in the party,” “the player told Halsin about the grove.” Lute adds a relational fact kernel beside the scalar tiers: a closed, n-ary fact database over a declared finite vocabulary. A document opts in simply by declaring relations; a document with none behaves exactly as before.
Entities, relations, ground facts
Section titled “Entities, relations, ground facts”Entity kinds enumerate their members (members:) or are engine-open (open: engine). A relation is a declared predicate with fixed arity and a typed argument signature; each argument ranges over an entity kind, a named enum, or bool. A fact is a relation applied to matching entities — it is symbolic (it holds or it does not; no numeric value slot). Every fact carries a valid-time interval, so retraction is a tombstone, never a deletion.
entities: character: { members: [shadowheart, halsin, player] } location: { members: [camp, grove, moonrise] }relations: inParty: { args: [character], tier: run } atLocation: { args: [character, location], tier: run, key: [0] } canReach: { args: [character, location], derive: true } wounded: { args: [character], tier: run, reserved: true } # asserted by the engine onlyfacts: - "inParty(shadowheart)" - "atLocation(player, camp)"Content writes deltas with the leaf directives ::assert and ::retract; the engine maintains the cumulative, time-scoped view. A functional key: auto-invalidates the superseded fact. Of the two, only ::retract admits wildcards (_); queries and rule bodies take them too (see The anonymous variable). A reserved: true relation (wounded above) is the engine’s to populate — the facts counterpart of an owner: engine state path. Content never asserts or retracts one (E-RELATION-RESERVED-WRITE); in the toolchain, a trace mock or a lute play engine: step supplies its facts.
::assert{ atLocation(shadowheart, grove) }::retract{ atLocation(shadowheart, _) }A reserved relation may also say when the engine changes it: changedOn: [battleEnd] names the occasions whose raise may write it (dsl 0.25.0 §6). It is a checker input, not an IR field, and it matters to cast presence: see Presence after engine events. changedOn on a relation that is not reserved: true, or naming an occasion no plugin declares, is E-RELATION-DECL.
A relation cannot take the name of a CEL call, macro or keyword — has, holds, count, has, now, and the like — nor of an after: call (completed, active) or a rule word (cel, not). has(lamp) could never be written, so declaring such a relation is E-RESERVED-NAME at its declaration, with a name to use instead (visited → wasAt). Every reserved name is on the Reserved names page.
Sub-kinds: subsetOf:
Section titled “Sub-kinds: subsetOf:”An id belongs to exactly one entity kind, and two kinds listing the same member is E-ENTITY-KIND-CLASH. A group that is part of a larger one, such as the companions among the people, is a sub-kind instead (dsl 0.24.0 §3):
---kind: sceneid: camp.rosterentities: person: { members: [isolde, corvin, hollis, player] } companion: { subsetOf: person, members: [isolde, corvin] }relations: inParty: { args: [companion], tier: run } trusts: { args: [person, person], tier: run } loyal: { args: [companion], derive: true }rules: - "loyal(P) :- inParty(P), trusts(P, player)"---
## The roster
::assert{ inParty(isolde) }::assert{ trusts(isolde, player) }@isolde{when="holds('loyal', ['isolde'])"}: I'm with you.A sub-kind’s members are members of its parent (dsl 0.26.0 §2.3): a member it shares with its parent, or with a sibling sub-kind, is no clash, and the parent need not list it again (restating it there is allowed and is no duplicate). A sub-kind is legal wherever a kind is: a relation argument, a per: index (State model), an occasion’s target domain, a kind target. A value of the sub-kind is also a value of the parent, so trusts(isolde, player) is legal and the rule above joins a companion with a person argument. The reverse does not hold: ::assert{ inParty(hollis) } is E-FACT-DOMAIN, because hollis is a person but not a companion.
Member order. Wherever a kind’s members are listed in order — a for="kind:…" beat’s presentations, the artifact’s forKind.members, a target domain, the members lute test and lute trace suggest mocking — a kind’s own members: come first (then any it gains with add:), followed by its sub-kinds’ members in the order the sub-kinds are declared, each member once, where it first appears. With hero: { members: [] }, then ssr: { subsetOf: hero, members: [aria, cyra] }, then sr: { subsetOf: hero, members: [bram] }, hero is aria, cyra, bram. Sub-kinds in different schema files follow file by file, the shallowest import first, then by path.
A sub-kind lists its own members:. A parent the document does not declare, a parent declared open:, a sub-kind declared open:, and a subsetOf: chain that loops back on itself are E-ENTITY-KIND-SHAPE, reported once at the schema line. Before 0.26.0 a sub-kind member missing from the parent’s list was an error too. A kind’s members may also be assembled across schema files with add:.
Whole-project lute check’s W-DOMAIN-UNREAD flags a kind that nothing reads. It counts a sub-kind’s subsetOf: parent, a kind used as a per: index, and a kind atom in a rule body or a holds(…) condition as reads, alongside relation arguments. The warning lands on the line of the schema that declares the kind, or on the document’s own entities: key, rather than on the first importer.
Two id spaces that spell an id the same
Section titled “Two id spaces that spell an id the same”If two kinds list the same member only because two id spaces spell {id} the same, do not merge them: declare a shared root kind listing both spaces’ members and make each a sub-kind of it, {first}: { subsetOf: <root>, members: […] }. Every reference still checks against its own kind. Since 0.49.0 E-ENTITY-KIND-CLASH names this remedy:
entities: speaker: { members: [mira, vendor-reply] } person: { subsetOf: speaker, members: [mira] } vendor: { subsetOf: speaker, members: [vendor-reply] }Ids that are not names: providerRef
Section titled “Ids that are not names: providerRef”An entity or enum member is a name — letters, digits, _ or - — so an id from another system such as vendor:reply is E-PATH-IDENT. To validate ids that are not names, declare a provider (plugins/<id>/providers/, with a pinned snapshot in catalogDir) and type the attribute { providerRef: <provider> }. Since 0.49.0 E-PATH-IDENT for an entity or enum member says so. See Providers & catalog.
Exclusive relations: excludes:
Section titled “Exclusive relations: excludes:”Some relations can never hold together on the same arguments. Nobody is seen alive after they fell; a witness is not both truthful and a liar. A relation may declare the relations it excludes (dsl 0.25.0 §1):
entities: character: { members: [elias, maren] } place: { members: [gallery, landing] }relations: at: { args: [character, place], tier: run } damaged: { args: [place], tier: run } seen: { args: [character], tier: run } seenAfter: { args: [character], derive: true, excludes: [fell] } fell: { args: [character], derive: true } calm: { args: [character], tier: run, excludes: [panicked] } panicked: { args: [character], tier: run }rules: - "seenAfter(S) :- seen(S)" - "fell(S) :- at(S, gallery), damaged(gallery), not seenAfter(S)"The declaration is symmetric: fell excludes seenAfter without restating it, and the compiled RelationEntry carries the symmetric closure, sorted ("excludes": ["seenAfter"] on fell; absent when empty). Each partner must be a declared relation other than the relation itself, with the same argument kinds. Otherwise the declaration is E-RELATION-DECL: `dwon` is not a declared relation — did you mean `down`?, `near` takes [c, p] but `up` takes [c], or a relation that cannot exclude itself. Base and derived relations may exclude each other in any mix.
Exclusion is an invariant the author declares, not something the checker infers from the rules. Whole-project lute check then uses it everywhere it decides a relational guard (below):
holds('A', ['x']) && holds('B', ['x'])is false, so a guard that needs both is dead:E-ARM-DEADon a line or arm,E-BEAT-UNREACHABLEon a beat, and the message names the exclusion;!holds('B', ['x'])follows fromholds('A', ['x']): in the same guard, in an enclosing one, or becauseA(x)holds on every route to the guard. Such a guard isW-FACT-GUARANTEED, and cast presence reads it the same way. A guard over an enginereserved:relation counts too, inside the region it guards;- an
::assert{A(x)}whereB(x)holds on every route to it is the newE-FACT-EXCLUSIVE; - a rule that can only break the exclusion is the new
E-RULE-EXCLUSIVE, at the rule:dark(X) :- lit(X)withdarkexcludinglitderivesdarkonly wherelitholds on the same arguments, so every derivation breaks the exclusion. Fix the rule or the declaration.
A relation named in an excludes: pair counts as read, so declaring the pair never draws W-RELATION-UNREAD.
A storm scene asserts at(elias, gallery) and damaged(gallery), and panicked(maren) on every route. Then a dawn scene sequenced after: 'visited("storm")':
@narrator{when="holds('seenAfter', ['elias']) && holds('fell', ['elias'])"}: Both at once.@narrator{when="holds('seenAfter', ['elias']) && !holds('fell', ['elias'])"}: Elias walks on.::assert{ calm(maren) }./scenes/dawn.lute:20:17: error [E-ARM-DEAD] this gated line can never be shown: its `when` guard `holds('seenAfter', ['elias']) && holds('fell', ['elias'])` is provably false — `seenAfter(elias)` and `fell(elias)` can never hold together (`seenAfter` excludes `fell`)./scenes/dawn.lute:21:17: warning [W-FACT-GUARANTEED] guard `holds('seenAfter', ['elias']) && !holds('fell', ['elias'])` is redundant: `!holds('fell', ['elias'])` follows from this guard's `holds('seenAfter', ['elias'])`: `seenAfter` excludes `fell`./scenes/dawn.lute:22:1: error [E-FACT-EXCLUSIVE] `::assert{calm(maren)}` would make `calm(maren)` and `panicked(maren)` both hold: `panicked(maren)` is asserted on every route to here (./scenes/storm.lute:14), and `calm` excludes `panicked` — retract `panicked(maren)` first, or assert only where it does not holdWhen both facts are only possible, say panicked(maren) is asserted down one branch of the storm, there is no static verdict. The walk tools catch it instead, never silently: lute play prints ✗ exclusive: calm(maren) and panicked(maren) both hold under the write that made both hold and halts there (exit 1), and lute trace / lute test print the same line at the write and refuse the walk with E-FACT-EXCLUSIVE (exit 1). Seeded facts that already break an exclusion are refused before anything is walked. A derived relation is covered through its derivations: the check runs after the rules, so a write that makes some rule conclude the excluded partner is caught at that write. See Playing a story and Tracing.
Querying history with validAt
Section titled “Querying history with validAt”Because every fact carries a valid-time interval, a guard can ask whether a fact held at a past instant rather than right now: validAt('rel', ['args'], t), beside the valid-now holds('rel', ['args']) and count('rel', ['args']) OP n (see CEL expressions). It is admitted over base relations, and over derived ones whose rules carry no CEL guard in any feeding stratum — a guard reads scalar state, scalars keep no history, so a point-in-the-past derivation through one is ill-defined (E-VALIDAT-DERIVED).
t must be a narrativeTime expression, and there is exactly one an author can write: quest.<id>.activatedAt, the instant the engine stamps when a quest goes unset → active (see Quests & scenes). Declaring a narrativeTime path of your own is E-TEMPORAL-ARG, so before 0.8.0 reserved that slot validAt had no anchor to point at and the query was unusable in practice.
Together they express the gate a quest actually wants — not did this ever happen, but did it happen since the quest was taken up:
<quest id="theCoffeeDebt" title="Settle the coffee debt"> <objective id="visitedSinceAccept" title="Go back to the station" done="holds('arrivedSpace', ['station_front']) && !validAt('arrivedSpace', ['station_front'], quest.theCoffeeDebt.activatedAt)"/></quest>The fact is valid now and was not valid at the activation instant, so the objective completes only on a visit made after the debt was accepted — never on one the player had already made. (A tag and all of its attributes must sit on one physical line; that <objective …/> cannot be wrapped.)
Datalog derivation
Section titled “Datalog derivation”A relation marked derive: true is computed by rules: — Horn clauses, function-free, with stratified negation. Multiple rules for one head union.
rules: - "canReach(C, L) :- atLocation(C, L)" - "canReach(C, L2) :- canReach(C, L1), connected(L1, L2)"A rule is head :- body, where the body is a comma-separated conjunction of these literals:
| Literal | Example | Meaning |
|---|---|---|
| relation atom | atLocation(C, L) | a base or derived fact matches; variables are capitalized, constants are entity members |
| entity kind | character(C) | C ranges over the members of the entity kind character: a membership test, never a fact lookup |
| negation | not awake(P) | no matching fact holds (stratified: awake must not depend on this head) |
| anonymous variable | sawAt(W, '_', _) | each _ is a fresh variable that matches anything (dsl 0.24.0); under not, no matching tuple exists at all |
| inequality | A != B | two bound terms differ |
| scalar guard | cel("run.act == 1") | a CEL condition over scalar state, never a fact query |
| count | count(hasBadge(_)) >= 7 | dsl 0.26.0 §6: the number of matching facts compared with >= > <= < == !=; also countDistinct(R(…), V…) op n. See Counts in a rule body |
rules: - "suspect(P) :- character(P), not inParty(P)" - "rivals(A, B) :- inParty(A), inParty(B), A != B" - "canReach(player, moonrise) :- cel(\"run.act >= 2\")"Because there are no function symbols, the Herbrand base is finite: bottom-up evaluation reaches a least fixpoint in finitely many steps, so every derivation terminates — this is Datalog, not Prolog. Safety requires every head/negated/guard variable to appear in a positive body atom (an entity-kind atom such as character(P) counts); violations are E-DATALOG-UNSAFE. A rule whose head has no variables needs no positive atom at all, so a ground head behind a scalar guard alone — canReach(player, moonrise) :- cel("run.act >= 2") — is a legal rule. A negation cycle is E-DATALOG-UNSTRATIFIED, and a would-be function term is E-DATALOG-FUNCTION. A rule body may carry a scalar CEL guard (cel("run.act == 1")) but never a fact query — that firewall (E-DATALOG-GUARD-FACT) keeps every dependency visible to the analysis. Derived and reserved: relations are read-only to content. The whole layer reduces to data the engine evaluates deterministically; nothing is author-iterated.
An entity-kind atom is a test against the kind’s members, never a lookup of character(…) facts: no fact is ever asserted under a kind’s name. lute trace, test, play and run evaluate it that way in a join, under not, and in --derivation. Before 0.24.0 they looked for such facts, so a rule like inParty(P) :- companion(P), … passed check and derived nothing at run time.
The anonymous variable and countDistinct
Section titled “The anonymous variable and countDistinct”A _ in a rule body stands for a value the rule does not care about. Each _ is its own fresh variable, so the two in sawAt(W, '_', _) need not be equal. It never needs binding, so it does not affect safety. Under not it is existential: not sawAt(W, '_', _) holds when W saw nobody, anywhere. A _ in a rule head is E-DATALOG-PARSE, because a head argument must be a bound variable or a constant, and so is a _ in a comparison (X != _).
A condition that counts matching facts uses list-form calls. count('R', ['_'])
counts tuples, and countDistinct('R', ['_', '_'], 0) counts distinct values
at the zero-based position (the list element at that position must be _):
---kind: sceneid: inquest.hallentities: person: { members: [ada, bram, cole, dora] } place: { members: [dock, mill] }relations: sawAt: { args: [person, person, place], tier: run } testified: { args: [person], derive: true } silent: { args: [person], derive: true }rules: - "testified(W) :- sawAt(W, _, _)" - "silent(W) :- person(W), not sawAt(W, _, _)"facts: - "sawAt(ada, bram, dock)" - "sawAt(ada, cole, mill)" - "sawAt(bram, cole, dock)"---
## The inquest
@narrator{when="count('sawAt', ['_', '_', '_']) >= 3"}: Three sightings are on record.@narrator{when="countDistinct('sawAt', ['_', '_', '_'], 0) >= 2"}: At least two witnesses came forward.@narrator{when="holds('testified', ['bram']) && holds('silent', ['dora'])"}: Bram spoke. Dora saw nothing at all.Three sightings, two witnesses: ada saw two people and bram one. <Var> must be a variable that appears in the pattern, otherwise the call is E-CEL-PROFILE. Whole-project lute check decides countDistinct from its fact envelope as it does count, and trace, test and play evaluate it. Like count and holds, it reads the fact store, so it is forbidden in a rule guard (E-DATALOG-GUARD-FACT).
Counts in a rule body
Section titled “Counts in a rule body”Since dsl 0.26.0 §6 a rule body may count, using Datalog ground/pattern notation: count(R(…)) <op> n and countDistinct(R(…), V…) <op> n, where <op> is >=, >, <=, <, == or !=. A variable another literal of the rule binds is read, so the count is per binding; any other variable ranges over the facts:
---kind: sceneid: inquest.verdictentities: person: { members: [ada, bram, cole, dora] } place: { members: [dock, mill] }relations: sawAt: { args: [person, person, place], tier: run } corroborated: { args: [person], derive: true } caseOpen: { args: [place], derive: true }rules: - "corroborated(S) :- person(S), count(sawAt(_, S, _)) >= 2" - "caseOpen(dock) :- countDistinct(sawAt(W, _, dock), W) >= 2"facts: - "sawAt(ada, cole, dock)" - "sawAt(bram, cole, mill)" - "sawAt(bram, dora, dock)"---
## The verdict
@narrator{when="holds('corroborated', ['cole'])"}: Two people put Cole at the scene.@narrator{when="holds('caseOpen', ['dock'])"}: Two witnesses were at the dock.@narrator{when="!holds('corroborated', ['dora'])"}: Only one saw Dora.lute trace takes all three arms. The IR carries a count literal as { kind: "count", atom, distinct?, op, n }, and trace, test, play and run evaluate it one stratum above the counted relation, so the count is final before the head is derived. A count over a relation that depends on the rule’s own head has no such stratum and is E-RULE-AGGREGATE-CYCLE:
cr/cyc.lute:13:6: error [E-RULE-AGGREGATE-CYCLE] a rule deriving `popular` counts `known`, which depends on `popular` itself (cycle: `known`, `popular`) — a `count(…)` / `countDistinct(…)` may only read a relation its rule's head does not feed, so the count is final before the head is derivedThat rule was popular(P) :- person(P), count(known(_, P)) >= 2 beside known(A, B) :- popular(A), person(B). A gate that opens at seven badges is one rule, canPass(earthGymDoor) :- count(hasBadge(_)) >= 7, and lute scenario knowledge traces the count to the producers of the facts it counts.
@defs in a rule guard
Section titled “@defs in a rule guard”A rule guard may call any def a condition may, arguments included. Since 0.24.0 the guard is expanded against the project’s def table before anything reads it, so check, trace, test and play all see the same body:
---kind: sceneid: lamp.roomstate: run.day: { type: int, default: 1 }defs: firstDay: { type: bool, cel: "run.day == 1" }entities: item: { members: [lamp] }relations: lit: { args: [item], derive: true }rules: - "lit(lamp) :- cel(\"@firstDay\")"---
## The lamp room
@narrator{when="holds('lit', ['lamp'])"}: The lamp is already burning.Before 0.24.0 this passed check and then never derived: the guard was evaluated unexpanded, read as undecided, and the rule was silently dropped. The expanded body still has to pass the firewall, so a def that queries facts is E-DATALOG-GUARD-FACT there. A def the guard cannot expand is E-RULE-GUARD-DEF: an undefined name, a wrong argument count, or a $, which has no match subject in a rule. When a guard is still undecided at play time, a condition that queries the rule’s relation reads unknown rather than false, and play halts there and names what would decide it.
Entity-indexed state in a rule guard
Section titled “Entity-indexed state in a rule guard”A path declared with per: holds one int or double per member of a kind. A document names the member (run.approval.isolde), and a component’s ::set may index it through a param (run.approval[@who], checked at each ::use; see the state model). In a rule cel() guard, run.approval[P] reads the member bound to the rule variable P (dsl 0.24.0 §3):
---kind: sceneid: camp.firestate: run.approval: { type: int, default: 0, per: companion }entities: person: { members: [isolde, corvin, hollis] } companion: { subsetOf: person, members: [isolde, corvin] }relations: inParty: { args: [companion], tier: run } devoted: { args: [companion], derive: true }rules: - "devoted(P) :- inParty(P), cel(\"run.approval[P] >= 5\")"---
## Camp
::assert{ inParty(isolde) }::set{run.approval.isolde += 5}@isolde{when="holds('devoted', ['isolde'])"}: I would follow you anywhere.P must be bound by a positive body atom that ranges it over the index kind or one of its sub-kinds. Here inParty(P) ranges it over companion. Each way of getting that wrong has its own code:
Pbound only byperson(P), a wider kind, isE-FACT-DOMAIN: some binding (hollis) would read a path that is not declared;Pbound by no positive atom isE-DATALOG-UNSAFE;run.trust[P]over a path declared withoutper:isE-UNDECLARED.
Such a rule compiles grounded: one IR rule per member of the kind, each guard over a ground path, with the rule’s raw text suffixed by the binding. The rule above becomes two, one for each companion:
{"head": {"relation": "devoted", "terms": [{"kind": "const", "value": "isolde"}]}, "body": [{"kind": "atom", "atom": {"relation": "inParty", "terms": [{"kind": "const", "value": "isolde"}]}, "negated": false}, {"kind": "guard", "cel": "run.approval.isolde >= 5"}], "raw": "devoted(P) :- inParty(P), cel(\"run.approval[P] >= 5\") [P = isolde]"}IR guards therefore stay CEL over ground terms, and an engine needs nothing new to evaluate them. lute trace, play and run evaluate the same instances.
A rule variable beside a domain-typed path
Section titled “A rule variable beside a domain-typed path”A state path typed { domain: <kind> } holds one member of a kind: where the stalker is, which
companion leads. A rule cel() guard may compare such a path with a rule variable, == or !=
(dsl 0.27.0 §3). That is how state meets the fact layer. Here the orderly walks the ward (the
engine moves run.stalker), and a room is close when he is in a room next to it:
state: run.stalker: { type: { domain: room }, default: morgue, owner: engine }entities: room: { members: [lobby, morgue, chapel] }relations: adjacent: { args: [room, room], tier: run } close: { args: [room], derive: true }facts: - "adjacent(lobby, chapel)" - "adjacent(chapel, morgue)"rules: - "close(R) :- adjacent(R, S), cel(\"run.stalker == S\")"As with an indexed path, S must be bound by a positive body atom (adjacent(R, S) ranges it over
room), and the rule compiles grounded: one IR rule per member, its guard a plain comparison,
run.stalker == 'chapel' with raw suffixed [S = chapel]. A kind beat asks the question of its
own member with holds('close', [occasion.target]). Two shapes are E-CEL-PROFILE: comparing the
variable in any other way (run.hp > S), and comparing it with a path that is not typed by a kind
(run.hp == S). The path may also be typed by an enum that lists some of the kind’s members
(run.route: { type: { domain: route } } with route: [none, ren, mika], beside
suitor(S) over suitor: { members: [ren, mika, kai] }): the comparison holds for the members
both list, as the same comparison does in a beat’s when. A path whose kind or enum shares no
member with the variable’s kind is E-FACT-DOMAIN, since the comparison can never hold.
Derivation in trace, test, and play
Section titled “Derivation in trace, test, and play”The engine computes the minimal model at run time, and since 0.22.0 the toolchain computes the same one wherever it plays content. lute trace, lute test, and lute play load the project’s seed facts: and apply its rules: — stratified negation included — over the facts you mock and the facts content asserts along the way. All three share one evaluator with the reference runner (lute run), so the toolchain cannot disagree with itself about what a rule concludes.
A derived fact therefore behaves like any other. Given
entities: item: { members: [lamp] }relations: knows: { args: [item], tier: run } broken: { args: [item], tier: run } canMend: { args: [item], derive: true }rules: - "canMend(I) :- knows(I), not broken(I)"a guard holds('canMend', ['lamp']) is decided by the rule: mock knows(lamp) and it holds, add broken(lamp) and it does not. You never mock the conclusion, and “false because a negated premise holds” is testable. A mocked derived atom is still accepted; it joins the base facts like a seed. When a rule’s cel(…) guard reads state the trace has not decided, that rule derives nothing, and the trace reports the conclusion unknown and names the state path that would decide it.
derive: false — a key of a trace mock, a *.test.yaml, or a play script — or the --no-derive flag, which wins over the key, restores the 0.21 model: the seed facts: are not loaded, an unmocked derived atom is unknown, and a note names each derived relation read. A test written against that model (an unmocked derived atom reading unknown, or a seeded relation reading empty) pins derive: false to keep its verdict. See the Tracing guide and Playing a story.
To see why an atom holds, lute play --derivation <ATOM> (repeatable) prints its derivation at the end of the playthrough: the rule used, each premise’s own support — a seed fact, asserted, or derived in turn — and a negated premise shown (absent):
explain canMend(lamp): holds canMend(lamp) ⇐ canMend(I) :- knows(I), not broken(I) ├─ knows(lamp) (asserted) └─ not broken(lamp) (absent)When the atom does not hold, it lists every rule that could conclude it, with the premise that failed:
explain canMend(lamp): does not hold canMend(I) :- knows(I), not broken(I) ├─ knows(lamp) (asserted) └─ ✗ not broken(lamp) (but it holds: asserted)How whole-project lute check analyzes relational guards
Section titled “How whole-project lute check analyzes relational guards”A guard that presumes knowledge is the right tool — @eris{when="holds('knows', ['player', 'lumen'])"} shows the line only once the player knows. Since 0.20.0, whole-project lute check (lute check --project <DIR>) decides every relational query (holds(…), count(…)) in every guard slot — a line when=, a <choice when>, a <when test>, a ::jump guard, a lore entry when, a quest start/fail, an objective done/visibleWhen — as one of three verdicts:
- impossible — no seed, assert, rule, or engine relation anywhere in the project can produce a matching fact;
- guaranteed — a matching fact holds on every declared route reaching the guard;
- possible — anything else: some routes have it, some do not, or the analysis cannot separate them. This is the normal case for a guard, and it is silent.
The verdicts come from two sets:
- May — project-wide and flow-insensitive: every ground fact that can be live at any point of any run. The
facts:seeds, the fact of every::assertanywhere in the project root (scenes, quest<on>/<objective>bodies, lore entries) whose document is not proven unreachable, every fact of areserved:relation (the engine may populate any of them), and whatever therules:derive over that set. May only knows that some route can produce a fact, not when. It reads a negated body atom as satisfiable, with one exception (dsl 0.23.0): a negation over a seed fact that nothing in the project retracts or displaces through itskey:is false. With the seedinParty(shadowheart),suspect(P) :- character(P), not inParty(P)can never concludesuspect(shadowheart), so a guardholds('suspect', ['shadowheart'])is dead, whilesuspect(astarion)still follows. Once any::retract{inParty(shadowheart)}exists, the negation is satisfiable again. - Must — path-sensitive: the facts live on every route to one program point. Within a document it is a forward walk:
::assertadds a fact (and drops the one itskey:supersedes),::retractremoves every match,<branch>/<match>arms intersect where they rejoin (a branch with no unguarded choice, or a non-exhaustive match, also intersects with the set before the block, because no arm may run), and a<hub>body may run zero or more times. Across scenes it follows theafter:graph, exactly like the scalar envelope: a scene starts from the seeds plus the facts itsafter:formula guarantees —visited(A)contributes what holds whenAends,&&unions,||intersects, andcompleted(q)/active(q)contribute nothing. Quest bodies and lore entry bodies run at engine-chosen times, so they start from the seeds plus their own guards.
Four rules keep Must sound:
- Only monotone facts cross time the author does not control. A fact survives a document boundary only if no
::retractanywhere in the project matches it, no other assert shares itskey:tuple with a different value, and its relation is neitherreserved:nor has anopen: engineargument. Facts oftier: sceneandtier: questrelations never cross a document boundary — the engine clears them with their episode or quest. - Guards are assumptions. Inside
<when test="holds(F)">, a<choice when="holds(F)">, a guarded::jump, an<on when>, an objectivedone, an entrywhen, or — for the whole scene — a beat scene’s frontmatterwhen:, each positive top-level&&conjunctholds(F)with a groundFis in Must — so a nested guard on the same fact is shown redundant. A linewhen=guards only its own line. - A guard holds only as long as its fact can (0.50). A quest’s
start=is judged once, when the quest activates, but its body runs while the quest stays active, possibly after anewRunor a season opening. So aholds(F)ofstart=is in Must in the quest’s body only whenFcrosses and outlasts the quest: its relation istier: user/app,tier: run(the default) in atier="run"quest, ortier: season:<name>in atier="season:<name>"quest of the same season. In a default (user-tier) quest astart=fact of the defaultruntier is possible only. Likewise aquestComplete/questFailedhandler’s guard is judged when the quest settles, but under ajudge: beforeoccasion its body runs after the occasion’s beats and other quests’ grants: itswhen=adds to Must only facts that outlast that deferral — monotone (nothing in the project may retract or displace them) and not of atier: season:<name>relation. 0.49.1 reported a falseE-ARM-DEAD(orW-FACT-GUARANTEED) on a body test of such a fact (#203, #205). - Counts are intervals.
count(P)lies between the number of Must facts matchingPand the number of May facts matching it; a comparison againstnis decided when the whole interval falls on one side.
A guard may also assume that a lore entry was read (dsl 0.24.0 §6). Under entry.X.read (or entry.X.read == true), Must gains the facts that X’s body asserts on every route through it and that nothing in the project retracts. The player has read the entry, so its asserts have run. Take an entry that records a suspicion:
<entry id="keeperLog" category="place"> @narrator: The keeper's log names Isolde twice. ::assert{ suspects(isolde) }</entry>and a scene that offers a choice only to a player who has read it:
<branch id="ask"> <choice id="press" text="Ask about the log" when="entry.keeperLog.read"> @narrator{when="holds('suspects', ['isolde'])"}: The log named her. </choice> <choice id="leave" text="Leave"> @narrator: You leave the office. </choice></branch>The inner guard can only hold, so whole-project lute check reports it redundant and names the assert that guarantees it:
./scenes/office.lute:13:21: warning [W-FACT-GUARANTEED] guard `holds('suspects', ['isolde'])` is redundant: `suspects(isolde)` is asserted on every route to here (./lore/harbor.lute:9)Its negation, !holds('suspects', ['isolde']), would be a dead guard. An assert down only one arm of the entry’s <match> guarantees nothing. entry.X.everRead only says the entry was read in some run, possibly an earlier one, so it adds only the entry’s tier: user and tier: app facts, which a new run keeps. A run-tier fact like suspects is not added under everRead.
The verdict feeds the same decision procedure that already reports dead scalar guards, so a relational guard that can never hold is reported through the code its slot already owns, and a guaranteed one inside a guard is flagged as redundant:
| Slot | Can never hold | Always holds |
|---|---|---|
<when test>, <choice when>, content line when=, ::jump guard | E-ARM-DEAD | W-FACT-GUARANTEED (not ::jump) |
lore entry when | E-ENTRY-UNREACHABLE | W-FACT-GUARANTEED |
objective done | E-OBJECTIVE-UNSATISFIABLE | — (a predicate, not a guard) |
required objective visibleWhen | W-OBJECTIVE-HIDDEN | — |
quest start / fail | E-QUEST-UNREACHABLE (fail also when it always holds) | — |
A query the document cannot even state — an undeclared relation, a wrong arity, an argument outside a closed domain — stays undecided; its own error (E-RELATION-UNKNOWN, E-RELATION-ARITY, E-FACT-DOMAIN) owns it. E-ENTRY-UNREACHABLE is new with this analysis, and also fires in single-file lute check for an entry when that decides false on scalars alone. W-UNPROVEN-RELATIONAL, which used to mark every relational gate as “not proven”, is gone: its premise no longer holds, and naming it in --deny is a usage error.
A small example. The archive asserts one fact unconditionally and another only down one branch arm:
## The Archive
@narrator: The log is still open on the console.::assert{ knows(player, lumen) }<branch id="dig"> <choice id="readOn" text="Keep reading"> ::assert{ knows(player, heading) } @narrator: The heading was changed eleven years ago. </choice> <choice id="close" text="Close the log"> @narrator: You close it. </choice></branch>The bridge is sequenced after: 'visited("demo.archive")' and guards three lines:
## The Bridge
@eris{when="holds('knows', ['player', 'lumen'])"}: So you read the log.@eris{when="holds('knows', ['player', 'heading'])"}: Then you know where we are going.@eris{when="holds('knows', ['eris', 'heading'])"}: I changed it myself.Whole-project lute check settles the first and third and leaves the second alone — knows(player, heading) holds only if the player chose readOn, which is exactly what that guard is for:
./scenes/bridge.lute:10:13: warning [W-FACT-GUARANTEED] guard `holds('knows', ['player', 'lumen'])` is redundant: `knows(player, lumen)` is asserted on every route to here (./scenes/archive.lute:10)./scenes/bridge.lute:12:13: error [E-ARM-DEAD] this gated line can never be shown: its `when` guard `holds('knows', ['eris', 'heading'])` is provably false — no seed, assert, rule, or engine relation produces `knows(eris, heading)` under your declared routesThe same two sets decide counts: after the archive, count('knows', ['player', '_']) lies between 1 and 2, so a guard count('knows', ['player', '_']) >= 3 is dead and count('knows', ['player', '_']) >= 1 is redundant.
The analysis needs the whole project — an assert in a sibling document is invisible to a single file, and claiming “never asserted” from one file would be false — so it runs in whole-project lute check only. lute check <file> leaves each relational query undecided, but still sees a contradiction between two queries in one condition: holds(P) && !holds(P) is false whatever holds (dsl 0.23.0, see How a when is decided). To see why a guard was judged guaranteed, lute scenario --project <DIR> envelope --node <scene> lists the guaranteed facts at the scene’s entry beside the scalar tables (see envelopes). To see which documents produce the facts a guard queries, lute scenario --project <DIR> knowledge traces each queried atom through the rules to its producers, or to no producer (see Overviews).
Relations nothing reads
Section titled “Relations nothing reads”The converse problem is a relation that is written but never read. Whole-project lute check (dsl 0.24.0 §7) reports W-RELATION-UNREAD for a declared, non-reserved: relation that is asserted, seeded or derived, but that no condition queries (holds, count, countDistinct), no rule body uses, and no def reads. Its facts change nothing:
./world.schema.yaml:9:3: warning [W-RELATION-UNREAD] relation `rumor` is written (asserted, seeded, or derived) but never read: no condition queries it (`holds` / `count`), no rule body uses it, and no def reads it — the facts it records change nothing; read it where it matters, or drop itIt is reported once per project, at the declaration: the schema file’s line, or the document’s own relations: key. Play scripts and scenario tests are not reads, so a relation that only a test inspects still draws it. Its companion for defs is W-DEF-UNUSED (see State schemas).
Guards over facts not written yet
Section titled “Guards over facts not written yet”While a story is being written, a guard is often dead only because the scene that asserts its fact does not exist yet. lute check --project <DIR> --wip (dsl 0.23.0) reports E-ENTRY-UNREACHABLE, E-BEAT-UNREACHABLE, and E-OBJECTIVE-UNSATISFIABLE as warnings when the guard is dead only because a relation has no producer at all: no seed, no ::assert anywhere, no rule, and not reserved:. The warning’s note says so. A relation that has producers but can never match the query stays an error, as does anything the dead content causes downstream, such as an E-CONN-UNREACHABLE. See Work in progress.
Facts produced by reward effects
Section titled “Facts produced by reward effects”A plugin reward kind can declare effects: { asserts, retracts } (0.50). A fact such a reward writes is an ordinary fact at run time. Whole-project lute check reads each effect at the owner’s grant point:
| Reward | Grant point |
|---|---|
quest-level, no outcome | the quest’s completion, before its <on event="questComplete"> handlers |
quest-level, outcome="failed" | the quest’s failure, before its <on event="questFailed"> handlers |
| objective-level | the objective’s completion: the objective body’s entry after done |
A reward’s assert is possible only. It is a producer: it is in the May set, so it answers E-QUEST-UNREACHABLE and E-OBJECTIVE-UNSATISFIABLE (a quest gated on the fact is reachable), feeds W-RELATION-UNREAD and the scenario graph, and is recorded for E-FACT-EXCLUSIVE. It is in no Must set, guarded or not: not in the granting quest’s handler bodies, its objective bodies, its completion state, or anything gated on the quest. So a guard that tests the fact after the grant draws neither W-FACT-GUARANTEED nor E-ARM-DEAD. The reason is timing the checker cannot see: a judge: before occasion defers lifecycle handlers past other quests’ grants and handlers and the occasion’s beats, a user-tier quest stays complete across a run or season reset that clears its facts, and a host may seed a quest complete without replaying its grants.
- In Must an assert removes only what it displaces (an exclusive relation’s other value) and adds nothing.
- A retract, guarded or not, removes what it matches from Must, as a guarded
::retractdoes, and is recorded in May forE-FACT-EXCLUSIVE. A fact some grant retracts is not monotone and crosses into no later document. E-FACT-EXCLUSIVEreads the Must set at the reward’skind=, so it fires only against a fact that is Must from another source (a guard, a seed, an authored::assert).- Order within a reward: its
retracts, then itsasserts. Rewards go in declaration order, and objective rewards before quest rewards. - Each
<on event="questComplete">/questFailedhandler starts from the grant point’s state independently, less every fact any handler of the event may remove; itswhen=adds to Must only facts that outlast a deferral (monotone, and not of atier: season:<name>relation). - An
acceptseffect has no fact effect. It feeds reachability andW-QUEST-NEVER-ACCEPTED.
A reward with no target= that reads @target applies nothing, so it contributes no fact. lute scenario knowledge and lute lore name the reward as the producer. In the plugin-surfaces example:
$ lute scenario --project . knowledgeproject root: . knowledge (every fact-guarded condition, by document -> relations read -> producers):
quests/visits.lute quest `secondVisit` start: holds('met', ['mira']) met(mira) — produced by reward BADGE of quest `firstVisit`