Skip to main content

DSL Reference

Fabula provides a text DSL for defining patterns and graphs. The DSL compiles to the same types as the Rust builder API — every construct maps 1:1.

Pattern Syntax

[private] pattern <name> {
stage <event_var> {
<clause>+
}+

[concurrent {
stage <event_var> { <clause>+ }
stage <event_var> { <clause>+ }
...
}]*

[unless between <start_var> <end_var> { <clause>+ }]*
[unless after <start_var> { <clause>+ }]*
[unless { <clause>+ }]*
[temporal <left_var> <relation> <right_var>]*
[meta("<key>", "<value>")]*
[deadline <ticks>]
}

Private patterns

Prefix a pattern with private to suppress its matches and events from engine output. The engine still evaluates the pattern internally — useful for composition building blocks.

private pattern helper {
stage e1 { e1.type = "setup" }
}
pattern main_flow {
stage e1 { e1.type = "action" }
}
compose full = helper >> main_flow sharing(...)

Only full matches appear in output. helper matches are suppressed.

Sources

The left side of the dot identifies which node to query edges from. There are three kinds:

SyntaxMeaningExample
e1.labelStage anchor — the node this stage matches againste1.eventType = "enter" inside stage e1 { ... }
alice.labelLiteral node — a specific named node in the graphalice.trait = "impulsive"
?char.labelBound variable — must have been bound by -> ?char earlier?char.trait = "impulsive" after e1.actor -> ?char

Stage anchors do not need ? — they are implicitly variables within their stage. Bare identifiers that are not the stage anchor are treated as literal node names. Use ? to reference a bound variable.

Using ? on an unbound variable is a compile error:

stage e1 {
e1.eventType = "betray"
?char.trait = "impulsive" // ERROR: ?char not yet bound
}

Clauses

SyntaxMeaningBuilder equivalent
e1.label = "value"Literal string match (stage anchor).edge("e1", label, Str(value))
?char.label = "value"Match on bound variable.edge("char", label, Str(value))
alice.label = "value"Match on literal node.edge("alice", label, Str(value))
source.label = 42Literal number match.edge(source, label, Num(42.0))
source.label = trueLiteral boolean match.edge(source, label, Bool(true))
source.label -> ?varBind target to variable.edge_bind(source, label, var)
source.label -> nodeMatch node reference.edge(source, label, Node(node))
source.label < 0.5Value constraint (Lt).edge_constrained(source, label, Lt(0.5))
source.label > 10Value constraint (Gt).edge_constrained(source, label, Gt(10))
source.label <= 100Value constraint (Lte).edge_constrained(source, label, Lte(100))
source.label >= 0Value constraint (Gte).edge_constrained(source, label, Gte(0))
source.label > ?varCross-stage comparison (Gt) — target must be greater than the value bound to ?var.edge_gt_var(source, label, "var")
source.label < ?varCross-stage comparison (Lt).edge_lt_var(source, label, "var")
source.label = ?varCross-stage comparison (Eq) — target must equal bound value (not a binding — use -> to bind).edge_eq_var(source, label, "var")
source.label >= ?varCross-stage comparison (Gte).edge_gte_var(source, label, "var")
source.label <= ?varCross-stage comparison (Lte).edge_lte_var(source, label, "var")
! source.label = "value"Negated clause (literals/refs only).not_edge(source, label, value)

Important: = ?var (equality comparison against a bound variable's value) is distinct from -> ?var (bind or join). Use = when you want to compare a numeric/string value against a previously bound value. Use -> when you want to traverse and bind a node reference.

Negation (!) works with literal values (= "value", = 42, = true) and node references (-> node). It is not supported with value constraints (<, >, <=, >=), variable comparisons (> ?var), or variable bindings (-> ?var) — rewrite as the inverse constraint instead (e.g., ! e.x < 0.5 becomes e.x >= 0.5).

Concurrent Groups

Stages inside a concurrent { } block can match in any order. The engine tracks which stages in the group have been satisfied using a bitmask and advances the pattern past the group when all stages are matched.

pattern multi_signal_alert {
stage e1 {
e1.type = "anomaly_detected"
e1.sensor -> ?sensor
}
concurrent {
stage e2 {
e2.type = "temperature_spike"
e2.sensor -> ?sensor
}
stage e3 {
e3.type = "pressure_drop"
e3.sensor -> ?sensor
}
}
stage e4 {
e4.type = "shutdown"
e4.sensor -> ?sensor
}
}

In this pattern, after e1 matches, both e2 and e3 must match (in either order) before e4 can match. The shared variable ?sensor ensures all stages refer to the same sensor.

Rules:

  • A concurrent group must contain at least 2 stages.
  • Stages in the group can be interleaved with stages outside the group (the overall pattern order is: stages before the group, then the group stages in any order, then stages after the group).
  • Temporal ordering within a concurrent group is relaxed — stages in the same group are not required to be time-ordered relative to each other.
  • unless between cannot use two anchors that are both inside the same concurrent group (undefined temporal ordering). The compiler rejects this with an error.
  • Variables bound in any stage of a concurrent group are visible to all sibling stages in the same group (symmetric scoping).

Maps to PatternBuilder::unordered_group() in the Rust builder API.


Negation Windows

SyntaxMeaning
unless between e1 e3 { ... }Clauses must NOT match between e1 and e3
unless after e1 { ... }Clauses must NOT match after e1 (open-ended)
unless { ... }Global negation (first stage to last stage)

Temporal Constraints

temporal e1 before e2                   // qualitative only
temporal e1 before e2 gap 3..10 // gap in [3, 10]
temporal e1 before e2 gap ..10 // gap in [0, 10]
temporal e1 before e2 gap 3.. // gap in [3, infinity)
temporal e1 during e2 gap 5..50 // start margin in [5, 50]

All 13 Allen relations are supported: before, after, meets, met_by, overlaps, overlapped_by, during, contains, starts, started_by, finishes, finished_by, equals.

The optional gap keyword adds a metric bound (STN-style bounded difference constraint). The meaning of "gap" depends on the Allen relation — for before it's the separation between end(A) and start(B); for during it's the start margin; for overlaps it's the overlap duration. See the Temporal Model for details.

Metadata

Attach key-value metadata to a pattern with meta. Metadata is propagated to Match, SiftEvent, and scored match types. Place meta directives inside the pattern body, alongside stages, negations, and temporal constraints.

pattern betrayal {
stage e1 {
e1.eventType = "betray"
e1.actor -> ?char
}
meta("thread_type", "conflict")
meta("priority", "high")
}

Multiple meta directives are allowed. If the same key appears twice, the last value wins. Maps to PatternBuilder::metadata(key, value).

Deadline

Set a tick deadline for partial match expiration with deadline. If a partial match has been alive for more than this many ticks without completing, it is killed with SiftEvent::Expired during end_tick().

pattern time_sensitive {
stage e1 { e1.type = "offer" }
stage e2 { e2.type = "accept" }
deadline 10
}

The deadline must be a positive integer (>= 1). The compiler rejects deadline 0 and negative values. Maps to PatternBuilder::deadline(ticks).

Graph Syntax

graph {
@<timestamp> <source>.<label> = <value>
@<timestamp> <source>.<label> -> <node>
@<start>..<end> <source>.<label> = <value>
now = <number>
}

Edge Types

SyntaxValue Type
@1 e.type = "enter"String
@1 e.score = 42.5Number
@1 e.active = trueBoolean
@1 e.actor -> aliceNode reference
@1..10 e.type = "siege"Bounded interval [1, 10)

Edges without .. create open-ended intervals [start, infinity).

now

The now = <number> statement sets the graph's current time. If omitted, the playground uses max(timestamps) + 1.

Comments

Line comments start with //:

// This is a comment
pattern test {
stage e1 {
e1.type = "hello" // end-of-line comment
}
}

Complete Example

pattern violation_of_hospitality {
stage e1 {
e1.eventType = "enterTown"
e1.actor -> ?guest
}
stage e2 {
e2.eventType = "showHospitality"
e2.actor -> ?host
e2.target -> ?guest // joins on ?guest from e1
}
stage e3 {
e3.eventType = "harm"
e3.actor -> ?host // same host as e2
e3.target -> ?guest
}
unless between e1 e3 {
eMid.eventType = "leaveTown"
eMid.actor -> ?guest // same guest — if they leave, no violation
}
}

graph {
@1 e1.eventType = "enterTown"
@1 e1.actor -> alice
@2 e2.eventType = "showHospitality"
@2 e2.actor -> bob
@2 e2.target -> alice
@3 e3.eventType = "harm"
@3 e3.actor -> bob
@3 e3.target -> alice
now = 10
}

Variable source example

This pattern uses ?char to check a property on a bound variable:

pattern two_impulsive_betrayals {
stage e1 {
e1.eventType = "betray"
e1.actor -> ?char // bind ?char
?char.trait = "impulsive" // follow ?char, check its trait
}
stage e2 {
e2.eventType = "betray"
e2.actor -> ?char // same character betrays again
}
unless {
mid.eventType = "reconcile"
mid.actor -> ?char // mid is a scan root (no ?), ?char is a target binding
}
}

Note that mid in the negation block has no ? — it is a scan root (the engine searches for any node matching the clauses). But ?char on the right side of -> references the bound variable from the parent pattern.

Try this in the Pattern Playground.

Compose Syntax

Compose directives combine named patterns into larger patterns. Three operators are supported.

compose <name> = <pattern_a> >> <pattern_b> sharing(<var>, ...)   // sequence
compose <name> = <pattern_a> | <pattern_b> | <pattern_c> // exclusive choice
compose <name> = <pattern_a> | <pattern_b> nonexclusive // non-exclusive choice
compose <name> = <pattern> * <count> sharing(<var>, ...) // exact repeat
compose <name> = <pattern> * <min>..<max> sharing(<var>, ...) // repeat range
compose <name> = <pattern> * <min>.. sharing(<var>, ...) // repeat (unbounded)

Sequence (>>)

Creates a new pattern whose stages are A's stages followed by B's stages. Variables listed in sharing(...) are joined across the two patterns.

pattern setup {
stage e1 { e1.eventType = "promise" e1.actor -> ?char }
}
pattern payoff {
stage e2 { e2.eventType = "fulfill" e2.actor -> ?char }
}

compose promise_kept = setup >> payoff sharing(char)

Choice (|)

Registers all alternatives as separate patterns with a shared group. When one alternative completes, the engine kills active PMs for all sibling alternatives (exclusive choice).

compose crisis = war | famine | plague

Add nonexclusive after the alternatives to allow all of them to match independently (no group, no mutual kill):

compose any_crisis = war | famine | plague nonexclusive

Repeat (*)

Exact repeat (* N): creates a sequence of N copies of the same pattern. Variables listed in sharing(...) are joined across all copies (the same actor in each repetition). Each copy gets distinct repN_ prefixed variable names, so you can inspect individual repetition bindings.

compose three_strikes = offense * 3 sharing(offender)

Repeat range (* N..M): matches the sub-pattern at least N times, up to M times. Uses a looping engine with first/last bookends instead of unrolling. The first match binds first_ prefixed variables, subsequent matches overwrite last_ prefixed variables. Shared variables persist across all iterations. Completion is emitted at N occurrences; the engine continues matching up to M.

compose brute_force = login_fail * 5..10 sharing(account)   // 5 to 10 attempts
compose escalation = price_hike * 3..5 sharing(item) // 3 to 5 hikes

Unbounded repeat (* N..): matches at least N times with no upper limit. The engine loops indefinitely, emitting a completion at each occurrence >= N.

compose persistent = anomaly * 3.. sharing(sensor)   // 3 or more anomalies

Bindings available in repeat-range matches:

  • first_* — variables from the first match (e.g., first_actor, first_e)
  • last_* — variables from the most recent match (overwritten each iteration)
  • Shared variables — unprefixed, consistent across all iterations
  • repetition_count — available on the PartialMatch for inspection

Rules

  • All referenced patterns must be defined before the compose directive (no forward references).
  • sharing(...) is required for sequence and repeat when you want cross-pattern variable joins. Omit it for independent patterns.
  • Compose chains work: compose ab = a >> b then compose abc = ab >> c.
  • Variables are automatically renamed to avoid collisions (e.g., e1 becomes e1_0, e1_1).
  • Repeat range (* N..M, * N..) requires N >= 1. For bounded ranges, M >= N.

TypeMapper

By default, the DSL compiles patterns to Pattern<String, MemValue>. The TypeMapper trait lets you compile directly to a different type system.

TypeMapper trait

pub trait TypeMapper {
type L: Clone + Debug; // label type
type V: Clone + Debug; // value type

fn label(&self, s: &str) -> Result<Self::L, String>;
fn string_value(&self, s: &str) -> Result<Self::V, String>;
fn num_value(&self, n: f64) -> Result<Self::V, String>;
fn bool_value(&self, b: bool) -> Result<Self::V, String>;
fn node_ref(&self, name: &str) -> Result<Self::V, String>;
}

All methods return Result to support fallible mappings (e.g., looking up a label in a predicate registry).

MemMapper

The default mapper, producing Pattern<String, MemValue>. Used by parse_pattern() and parse_document().

Custom mappers

use fabula_dsl::{TypeMapper, parse_pattern_with};

struct WkMapper { labels: HashMap<String, u32> }

impl TypeMapper for WkMapper {
type L = u32;
type V = paracausality::Value;

fn label(&self, s: &str) -> Result<u32, String> {
self.labels.get(s).copied()
.ok_or_else(|| format!("unknown predicate '{}'", s))
}
fn string_value(&self, s: &str) -> Result<Value, String> { Ok(Value::Str(s.into())) }
fn num_value(&self, n: f64) -> Result<Value, String> { Ok(Value::Num(n)) }
fn bool_value(&self, b: bool) -> Result<Value, String> { Ok(Value::Bool(b)) }
fn node_ref(&self, name: &str) -> Result<Value, String> { Ok(Value::Entity(name.parse()?)) }
}

let pattern = parse_pattern_with("pattern test { stage e { e.type = \"harm\" } }", &WkMapper { .. })?;
// pattern is Pattern<u32, Value>

ParsedDocument<L, V>

parse_document() returns ParsedDocument (defaults to <String, MemValue>). parse_document_with(input, &mapper) returns ParsedDocument<M::L, M::V>.

pub struct ParsedDocument<L = String, V = MemValue> {
pub patterns: Vec<Pattern<L, V>>,
pub graphs: Vec<MemGraph>, // always MemGraph (graphs are test-only)
}

Functions

FunctionReturnsDescription
parse_pattern(input)Result<Pattern<String, MemValue>>Parse a single pattern with MemMapper.
parse_pattern_with(input, mapper)Result<Pattern<M::L, M::V>>Parse a single pattern with a custom mapper.
parse_graph(input)Result<MemGraph>Parse a graph definition.
parse_document(input)Result<ParsedDocument>Parse a full document (patterns + graphs + composes) with MemMapper.
parse_document_with(input, mapper)Result<ParsedDocument<M::L, M::V>>Parse a full document with a custom mapper.
compile_pattern_body(name, body)Result<Pattern<String, MemValue>>Compile a PatternBody (from parse_pattern_body()) with a name, using MemMapper.
compile_pattern_body_with(name, body, mapper)Result<Pattern<M::L, M::V>>Compile a PatternBody with a custom mapper.

Composable Parser API

The parser exposes entry points for downstream DSLs that embed fabula pattern syntax within their own blocks. This lets a salience-DSL or storylet-DSL tokenize with fabula's lexer, delegate pattern parsing to fabula's parser, and resume parsing its own syntax afterward.

PatternBody

The interior of a pattern — stages, negations, temporal constraints, metadata, and deadline — without the pattern name { } wrapper.

pub struct PatternBody {
pub stages: Vec<StageAst>,
pub negations: Vec<NegationAst>,
pub temporals: Vec<TemporalAst>,
pub metadata: Vec<(String, String)>,
pub deadline: Option<f64>,
pub unordered_groups: Vec<Vec<usize>>,
}

Parser methods

Parser::parse_pattern_body

Parse the body of a pattern — stages, negations, temporal constraints, metadata, and deadline — without the pattern name { } wrapper. Stops when it sees } or EOF but does NOT consume the closing brace.

This is the primary composability entry point for downstream DSLs.

pub fn parse_pattern_body(&mut self) -> Result<PatternBody, ParseError>

Parser::from_tokens_at

Create a parser starting at a specific position in a token stream. Use this to resume parsing from where a previous parser left off.

pub fn from_tokens_at(tokens: Vec<Token>, pos: usize) -> Self

Parser::pos

Current cursor position in the token stream.

pub fn pos(&self) -> usize

Parser::into_inner

Consume the parser, returning the token stream and cursor position. Use this to recover the tokens after parsing a sub-section.

pub fn into_inner(self) -> (Vec<Token>, usize)

Downstream usage example

use fabula_dsl::lexer::Lexer;
use fabula_dsl::parser::Parser;
use fabula_dsl::compiler::compile_pattern_body_with;

// 1. Tokenize everything with fabula's lexer
let tokens = Lexer::new(storylet_source).tokenize()?;
let mut parser = Parser::new(tokens);

// 2. Parse your own DSL syntax up to the embedded pattern body
// (using parser.peek(), parser.advance(), parser.expect(), etc.)

// 3. Hand off to fabula's parser for the pattern body
let body = parser.parse_pattern_body()?;
parser.expect(TokenKind::RBrace)?; // consume closing brace

// 4. Compile the pattern body with a name
let pattern = compile_pattern_body_with("my_pattern", &body, &my_mapper)?;

// 5. Resume parsing your own DSL syntax
// The parser cursor is right after the pattern body

All parsing primitives (parse_stage, parse_negation, parse_temporal, parse_clause, peek, advance, at_eof, check, expect, expect_ident, expect_number, error) are public stable API for downstream DSL consumers.

Lexer Token Reference

The Lexer produces a stream of Token values. Downstream DSLs that reuse fabula's lexer (via Lexer::new(source).tokenize()) have access to all token types. The tokens used by fabula's own parser are marked; unmarked tokens exist for downstream DSL consumers.

Keywords: Pattern, Stage, Unless, Between, After, Graph, Now, Temporal, True, False, Compose, Sharing, Concurrent

Symbols (used by fabula):

TokenCharacter(s)
LBrace / RBrace{ }
LParen / RParen( )
Dot / DotDot. ..
Arrow->
Eq= (also ==)
Lt / Gt / Lte / Gte< > <= >=
GtGt>>
Bang!
At@
Question?
Pipe|
Star*
Comma,

Symbols (for downstream DSLs — not consumed by fabula's parser):

TokenCharacter(s)Intended use
Plus+Delta semantics (adjust ?e2.depth + 1)
Minus-Subtraction / negative prefix. Note: -5 lexes as Minus, Number(5.0), not Number(-5.0). The parser's expect_number() handles optional leading Minus.
Colon:Key-value syntax (lifecycle: oneshot)
Semicolon;Statement separator

Literals: Ident(String), String(String), Number(f64), Eof

Strings: Both single-quoted ("...") and triple-quoted ("""...""") strings produce the same TokenKind::String. Triple-quoted strings allow newlines and embedded " characters — useful for multi-line content blocks like prompt templates.