Skip to main content

Pattern Cookbook

Learning objective: Define and debug complex temporal graph patterns for common scenarios.

Eight worked recipes, each showing a problem, the pattern code, and usage guidance. All examples use MemGraph and MemValue.

Recipe 1: Repeated behavior by the same actor

Problem: Find two betrayals by the same impulsive character, with no reconciliation between them.

Pattern

PatternBuilder::<String, MemValue>::new("two_impulsive_betrayals")
.stage("e1", |s| {
s.edge("e1", "eventType".into(), MemValue::Str("betray".into()))
.edge_bind("e1", "actor".into(), "char")
.edge("char", "trait".into(), MemValue::Str("impulsive".into()))
})
.stage("e2", |s| {
s.edge("e2", "eventType".into(), MemValue::Str("betray".into()))
.edge_bind("e2", "actor".into(), "char")
})
.unless_global(|neg| {
neg.edge("mid", "eventType".into(), MemValue::Str("reconcile".into()))
.edge_bind("mid", "actor".into(), "char")
})
.build()

The variable "char" appears in both stages. This forces both betrayals to involve the same actor. The first stage also checks a persistent property (trait = impulsive) on that character. The unless_global negation blocks the match if the character reconciled at any point between the two betrayals.

Matching graph

let mut g = MemGraph::new();
g.add_str("alice", "trait", "impulsive", 0);
g.add_str("ev1", "eventType", "betray", 1);
g.add_ref("ev1", "actor", "alice", 1);
g.add_str("ev2", "eventType", "betray", 3);
g.add_ref("ev2", "actor", "alice", 3);
g.set_time(10);

let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
engine.register(pattern);
let matches = engine.evaluate(&g);
assert_eq!(matches.len(), 1);
assert_eq!(
matches[0].bindings["char"],
BoundValue::Node("alice".into())
);

Non-matching graph

let mut g = MemGraph::new();
g.add_str("alice", "trait", "cautious", 0); // not impulsive
g.add_str("ev1", "eventType", "betray", 1);
g.add_ref("ev1", "actor", "alice", 1);
g.add_str("ev2", "eventType", "betray", 3);
g.add_ref("ev2", "actor", "alice", 3);
g.set_time(10);

why_not output

Pattern: two_impulsive_betrayals
Stage "e1": Unmatched
?e1 --["eventType"]--> Literal(Str("betray"))
=> matched: false, reason: "?e1 is not bound"

The first stage fails because no event has both eventType = betray AND an actor with trait = impulsive. The why_not output reports that ?e1 is not bound because the scan for the first clause does not propagate bindings to subsequent clauses during gap analysis.

Recipe 2: Violation with exception (negation between)

Problem: Find a promise followed by a broken promise by the same person, unless they apologized between the two events.

Pattern

PatternBuilder::<String, MemValue>::new("broken_promise")
.stage("e1", |s| {
s.edge("e1", "eventType".into(), MemValue::Str("promise".into()))
.edge_bind("e1", "actor".into(), "person")
})
.stage("e2", |s| {
s.edge(
"e2",
"eventType".into(),
MemValue::Str("break_promise".into()),
)
.edge_bind("e2", "actor".into(), "person")
})
.unless_between("e1", "e2", |neg| {
neg.edge(
"apology",
"eventType".into(),
MemValue::Str("apologize".into()),
)
.edge_bind("apology", "actor".into(), "person")
})
.build()

Matching graph

let mut g = MemGraph::new();
g.add_str("ev1", "eventType", "promise", 1);
g.add_ref("ev1", "actor", "alice", 1);
g.add_str("ev2", "eventType", "break_promise", 3);
g.add_ref("ev2", "actor", "alice", 3);
g.set_time(10);
// No apology between t=1 and t=3 -> match

Non-matching graph

let mut g = MemGraph::new();
g.add_str("ev1", "eventType", "promise", 1);
g.add_ref("ev1", "actor", "alice", 1);
g.add_str("ev_apology", "eventType", "apologize", 2);
g.add_ref("ev_apology", "actor", "alice", 2); // apology at t=2
g.add_str("ev2", "eventType", "break_promise", 3);
g.add_ref("ev2", "actor", "alice", 3);
g.set_time(10);
// Apology at t=2 is between e1 (t=1) and e2 (t=3) -> negated

why_not output

For negation-blocked patterns, why_not shows all stages as matched (the positive clauses succeed). The negation is not reported by why_not -- it only analyzes positive stages. To debug negation issues, inspect the batch results directly: if evaluate returns 0 matches but all stages look correct in why_not, a negation window is blocking.

Recipe 3: Numeric threshold (edge_constrained)

Problem: Find a loyalty check event where the loyalty value is below 0.5.

Pattern

let pattern = PatternBuilder::<String, MemValue>::new("low_loyalty")
.stage("e", |s| {
s.edge(
"e",
"eventType".into(),
MemValue::Str("loyalty_check".into()),
)
.edge_constrained(
"e",
"loyalty".into(),
ValueConstraint::Lt(MemValue::Num(0.5)),
)
})
.build();

Matching graph

let mut g = MemGraph::new();
g.add_str("ev1", "eventType", "loyalty_check", 1);
g.add_num("ev1", "loyalty", 0.3, 1); // 0.3 < 0.5
g.set_time(10);

Non-matching graph

let mut g = MemGraph::new();
g.add_str("ev1", "eventType", "loyalty_check", 1);
g.add_num("ev1", "loyalty", 0.8, 1); // 0.8 is NOT < 0.5
g.set_time(10);

why_not output

Pattern: low_loyalty
Stage "e": Unmatched
?e --["eventType"]--> Literal(Str("loyalty_check"))
=> matched: false, reason: "?e is not bound"

The stage reports unmatched because gap analysis evaluates from an empty binding context. The event exists but the constraint on the second clause (loyalty < 0.5) fails.

Recipe 4: Overlapping events (explicit Allen constraint)

Problem: Find a sortie that happened during a siege. Both events have bounded intervals (start and end times).

Pattern

let pattern = PatternBuilder::<String, MemValue>::new("sortie_during_siege")
.stage("siege", |s| {
s.edge("siege", "eventType".into(), MemValue::Str("siege".into()))
})
.stage("sortie", |s| {
s.edge("sortie", "eventType".into(), MemValue::Str("sortie".into()))
})
.temporal("sortie", AllenRelation::During, "siege")
.build();

The During relation means the sortie's interval is entirely contained within the siege's interval.

Matching graph

let mut g = MemGraph::new();
g.add_edge_bounded(
"ev_siege",
"eventType",
MemValue::Str("siege".into()),
1,
100,
);
g.add_edge_bounded(
"ev_sortie",
"eventType",
MemValue::Str("sortie".into()),
3,
5,
);
g.set_time(4); // Both intervals active at t=4
// sortie [3, 5) is During siege [1, 100) -> match

Both intervals must be bounded for Allen relation checking to work. The query time (set_time) must be within both intervals so the edges are visible.

Non-matching graph

let mut g = MemGraph::new();
g.add_edge_bounded("ev_siege", "eventType", MemValue::Str("siege".into()), 1, 4);
g.add_edge_bounded(
"ev_sortie",
"eventType",
MemValue::Str("sortie".into()),
3,
7,
);
g.set_time(3);
// sortie [3, 7) is NOT During siege [1, 4) -- sortie extends past siege
// The Allen relation here is OverlappedBy, not During

why_not output

why_not does not currently report explicit temporal constraint failures. It only analyzes stage clauses. If all stages match in why_not but evaluate returns nothing, check your temporal constraints and interval bounds.

Recipe 5: Absence detection (unless_after)

Problem: Find a promise that was never fulfilled afterward (up to the current time).

Pattern

PatternBuilder::<String, MemValue>::new("unfulfilled_promise")
.stage("e1", |s| {
s.edge("e1", "eventType".into(), MemValue::Str("promise".into()))
.edge_bind("e1", "actor".into(), "person")
})
.unless_after("e1", |neg| {
neg.edge(
"fulfillment",
"eventType".into(),
MemValue::Str("fulfill".into()),
)
.edge_bind("fulfillment", "actor".into(), "person")
})
.build()

unless_after creates a negation window from e1 to "now" (the graph's current time). If a fulfillment event by the same person exists anywhere after the promise, the match is blocked.

Matching graph

let mut g = MemGraph::new();
g.add_str("ev1", "eventType", "promise", 1);
g.add_ref("ev1", "actor", "alice", 1);
g.set_time(10);
// No fulfill event by alice after t=1 -> match

Non-matching graph

let mut g = MemGraph::new();
g.add_str("ev1", "eventType", "promise", 1);
g.add_ref("ev1", "actor", "alice", 1);
g.add_str("ev2", "eventType", "fulfill", 5);
g.add_ref("ev2", "actor", "alice", 5);
g.set_time(10);
// fulfill by alice at t=5, which is after promise at t=1 -> negated

Note: unless_after is a single-stage pattern with negation. The positive part is just one stage. The result changes over time -- a promise is "unfulfilled" until a fulfillment event arrives.

Recipe 6: Multi-clause negation (all clauses must match)

Problem: Find a start-to-end sequence, negated only if the same person left between them. A different person leaving should not block the match.

Pattern

PatternBuilder::<String, MemValue>::new("kept_promise")
.stage("e1", |s| {
s.edge("e1", "eventType".into(), MemValue::Str("promise".into()))
.edge_bind("e1", "actor".into(), "person")
})
.stage("e2", |s| {
s.edge("e2", "eventType".into(), MemValue::Str("fulfill".into()))
.edge_bind("e2", "actor".into(), "person")
})
.unless_between("e1", "e2", |neg| {
neg.edge("mid", "eventType".into(), MemValue::Str("leave".into()))
.edge_bind("mid", "actor".into(), "person")
})
.build()

The negation has two clauses: eventType = leave AND actor = ?person. Both must match the same entity within the window. A leave event by a different person satisfies the first clause but fails the second (the actor binding does not match), so the negation does not fire.

Matching graph (different person leaves)

let mut g = MemGraph::new();
g.add_str("ev1", "eventType", "promise", 1);
g.add_ref("ev1", "actor", "alice", 1);
g.add_str("ev_leave", "eventType", "leave", 2);
g.add_ref("ev_leave", "actor", "bob", 2); // bob leaves, not alice
g.add_str("ev2", "eventType", "fulfill", 3);
g.add_ref("ev2", "actor", "alice", 3);
g.set_time(10);
// bob's leave does not block alice's pattern -> match

Non-matching graph (same person leaves)

let mut g = MemGraph::new();
g.add_str("ev1", "eventType", "promise", 1);
g.add_ref("ev1", "actor", "alice", 1);
g.add_str("ev_leave", "eventType", "leave", 2);
g.add_ref("ev_leave", "actor", "alice", 2); // alice leaves
g.add_str("ev2", "eventType", "fulfill", 3);
g.add_ref("ev2", "actor", "alice", 3);
g.set_time(10);
// alice's leave at t=2 is between t=1 and t=3, all clauses match -> negated

The key insight: negation blocks fire only when ALL clauses in the block are satisfied by the same entity. Partial matches on a negation block (only some clauses match) do not trigger negation. This lets you write precise negation conditions that reference the pattern's bound variables.

Recipe 7: Cross-stage value comparison (escalation)

Problem: Detect when a price increases between two orders — "price_B > price_A."

Pattern

let pattern = PatternBuilder::new("escalating_price")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("order".into()))
.edge_bind("e1", "price".into(), "base_price")
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("order".into()))
.edge_gt_var("e2", "price".into(), "base_price")
})
.build();

Stage 1 binds the price to ?base_price. Stage 2 uses edge_gt_var to require the second price is strictly greater. The engine resolves GtVar("base_price") to Gt(value) using the PM's bindings at match time.

DSL equivalent

pattern escalating_price {
stage e1 { e1.type = "order" e1.price -> ?base_price }
stage e2 { e2.type = "order" e2.price > ?base_price }
}

All five comparison operators work: > ?var, < ?var, >= ?var, <= ?var, = ?var. The = ?var form compares the edge value against the bound variable's value — it is not a binding (use -> ?var for that).

Range check (two constraints)

Combine two cross-stage constraints for range checks:

let pattern = PatternBuilder::new("in_range_reading")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("bounds".into()))
.edge_bind("e1", "low".into(), "low")
.edge_bind("e1", "high".into(), "high")
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("reading".into()))
.edge_gt_var("e2", "value".into(), "low")
.edge_lt_var("e2", "value".into(), "high")
})
.build();

This matches when low < value < high, where low and high were bound in a prior stage.

Recipe 8: Threshold detection with repeat range

Problem: Detect 3 or more failed logins from the same account — brute force detection.

Pattern

let attempt = PatternBuilder::new("login_fail")
.stage("e", |s| {
s.edge("e", "type".into(), MemValue::Str("login_fail".into()))
.edge_bind("e", "account".into(), "account")
})
.build();

let pattern = fabula::compose::repeat_range("brute_force", &attempt, 3, None, &["account"]);

repeat_range with min=3, max=None means "3 or more total occurrences." The account variable is shared across all iterations, so only failures for the same account are counted.

DSL equivalent

pattern login_fail {
stage e { e.type = "login_fail" e.account -> ?account }
}
compose brute_force = login_fail * 3.. sharing(account)

What you get in the match

  • first_e, first_account — the first failed login (where the attack started)
  • last_e, last_account — the most recent failed login
  • account — the shared target account (same across all iterations)
  • repetition_count on the PartialMatch — total number of matched occurrences

Bounded range

For "between 3 and 5 attempts" (stop tracking after 5):

pattern login_fail {
stage e { e.type = "login_fail" e.account -> ?account }
}
compose moderate_brute = login_fail * 3..5 sharing(account)

Exact count (unchanged)

For exactly 3 attempts (fully unrolled, distinct per-repetition bindings):

pattern login_fail {
stage e { e.type = "login_fail" e.account -> ?account }
}
compose three_strikes = login_fail * 3 sharing(account)

Recipe 9: Concurrent signals (unordered stages)

Problem: Detect when multiple signals occur in any order before a confirmation. A sensor triggers an alarm, then both a temperature spike AND a pressure drop happen (in either order), then a shutdown occurs.

DSL

pattern multi_signal_shutdown {
stage e1 {
e1.type = "alarm"
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
}
}

Stages e2 and e3 are in a concurrent { } block — they can match in any order. The shared variable ?sensor ensures all stages refer to the same sensor. Stage e1 must come before both concurrent stages, and e4 must come after both.

Builder API equivalent

let pattern = PatternBuilder::<String, MemValue>::new("multi_signal_shutdown")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("alarm".into()))
.edge_bind("e1", "sensor".into(), "sensor")
})
.unordered_group(|g| {
g.stage("e2", |s| {
s.edge(
"e2",
"type".into(),
MemValue::Str("temperature_spike".into()),
)
.edge_bind("e2", "sensor".into(), "sensor")
})
.stage("e3", |s| {
s.edge("e3", "type".into(), MemValue::Str("pressure_drop".into()))
.edge_bind("e3", "sensor".into(), "sensor")
})
})
.stage("e4", |s| {
s.edge("e4", "type".into(), MemValue::Str("shutdown".into()))
.edge_bind("e4", "sensor".into(), "sensor")
})
.build();

Note: unless_between cannot use two anchors that are both inside the same concurrent group (undefined temporal ordering). The compiler rejects this at compile time.

Next steps