Narrative Sifting
Social simulations produce hundreds of events -- characters meet, betray, reconcile, travel. Most are mundane. Sifting finds the narratively interesting subsequences: arcs, violations, escalations.
This tutorial builds three patterns that a game's narrative manager might run every tick to surface story-worthy moments and rank them by surprise.
| Time | ~15 minutes |
| Prerequisites | What is Sifting? |
1. Violation of Hospitality
The canonical sifting example from Kreminski's Winnow paper. A guest enters town, a host shows hospitality, then the host harms the guest -- unless the guest left between entry and harm. The "unless" clause is what makes this a sifting pattern rather than a simple sequence query.
Result: 1 match -- Bob showed hospitality to Alice then harmed her. Dave also showed hospitality to Carol then harmed her, but Carol left town at t=3, so unless between e1 e3 kills that match.
The negation window spans e1 to e3 (entry to harm), not e2 to e3 (hospitality to harm). A guest who leaves before receiving hospitality but returns later would not trigger the exception. Choose your window boundaries deliberately.
2. Escalating Conflict
Two acts of aggression between the same pair, with the second more severe than the first. The cross-stage comparison e2.severity > ?sev enforces escalation -- same-severity or de-escalating conflicts do not match. Reconciliation between the two acts kills the match.
Result: 1 match -- Macbeth insulted Duncan (severity 2) then attacked him (severity 5). Banquo also escalated against Macbeth (3 to 7), but the reconciliation at t=4 kills that match.
e2.severity > ?sev is a cross-stage comparison: the value bound to ?sev in stage 1 becomes the threshold for stage 2. This is how you express "more severe than before" without hardcoding levels.
3. Broken Promise with Surprise Scoring
The broken promise pattern from Sifting by Example detects when a character promises then betrays with no fulfillment between. Run it first:
Result: 2 matches -- both Macbeth and Lady Macbeth broke their promises to Duncan. But are both equally interesting? That depends on context.
Ranking by surprise
Finding matches is step one. A narrative manager also needs to rank them. SurpriseScorer computes Shannon surprise: patterns that fire often score low; rare patterns score high.
use fabula::scoring::SurpriseScorer;
// Build the broken-promise pattern.
let broken_promise = PatternBuilder::<String, MemValue>::new("broken_promise")
.stage("e1", |s| {
s.edge("e1", "type".into(), MemValue::Str("promise".into()))
.edge_bind("e1", "actor".into(), "char")
.edge_bind("e1", "target".into(), "recipient")
})
.stage("e2", |s| {
s.edge("e2", "type".into(), MemValue::Str("betray".into()))
.edge_bind("e2", "actor".into(), "char")
.edge_bind("e2", "target".into(), "recipient")
})
.unless_between("e1", "e2", |neg| {
neg.edge("mid", "type".into(), MemValue::Str("fulfill".into()))
.edge_bind("mid", "actor".into(), "char")
.edge_bind("mid", "target".into(), "recipient")
})
.build();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();
let broken_promise_idx = engine.register(broken_promise);
let mut scorer = SurpriseScorer::new();
// Baseline: broken promises happen ~30% of rounds in this simulation.
scorer.set_baseline(broken_promise_idx, 0.3);
// Build a graph where the pattern matches.
let mut graph = MemGraph::new();
graph.add_str("e1", "type", "promise", 1);
graph.add_ref("e1", "actor", "macbeth", 1);
graph.add_ref("e1", "target", "duncan", 1);
graph.add_str("e2", "type", "betray", 3);
graph.add_ref("e2", "actor", "macbeth", 3);
graph.add_ref("e2", "target", "duncan", 3);
graph.set_time(10);
// Observe several rounds to build up frequency data.
for _ in 0..20 {
let matches = engine.evaluate(&graph);
scorer.observe(&matches, engine.patterns());
}
// Score the latest round's matches.
let matches = engine.evaluate(&graph);
let scored = scorer.score(&matches, engine.patterns());
for m in &scored {
println!("{}: surprise = {:.2} bits", m.pattern, m.surprise);
}
If broken promises fire in 18 of 20 rounds, surprise is low (~0.2 bits). If they fire in 2 of 20 rounds, surprise is high (~3.9 bits). A betrayal by a consistently loyal character in a peaceful simulation is genuinely surprising; the same betrayal in a war simulation is background noise.
For property-level surprise -- scoring which character betrayed whom rather than just whether any betrayal happened -- use StuScorer with extracted properties like actor_trait=loyal or target_role=king. See the Scoring Reference for the full API.
Putting it together
A narrative manager registers all three patterns, runs them each tick, and surfaces the top-scoring matches to the player or AI director:
- Detect --
engine.on_edge_added()feeds simulation events incrementally - Complete --
engine.end_tick()finalizes the tick and produces aTickDelta - Score --
SurpriseScorerranks completed matches by unexpectedness - Surface -- highest-scoring matches become dialogue hints, camera focus, or journal entries
For MCTS-driven narrative management, the fabula-narratives crate adds thread tracking (MICE quotient open/close pairs), tension arc classification (rising/falling/plateau/peak/valley), and pivot detection (Jensen-Shannon divergence between tick distributions). These feed into a composite scorer that evaluates candidate actions by their narrative quality. See the Narrative Scoring Reference.
Mapping your data
Simulation events map to fabula edges as follows:
| Real-world field | Fabula edge |
|---|---|
| eventID (unique per event) | source node |
| eventType (meet, betray, travel) | label value |
| actor, target, location | target nodes |
| tick or simulation step | interval start |
For events with duration (e.g., a siege), use [start_tick, end_tick). For instantaneous events, use [tick, tick+1).
How fabula compares
- vs Felt (Kreminski 2019): Fabula is a Rust port/extension of Felt. Felt introduced sifting patterns with variable joins and negation. Fabula extends with Allen interval algebra (13 temporal relations + metric gaps), composition operators (sequence, choice, repeat), and surprise scoring for ranking matches.
- vs Winnow (2021): Winnow added incremental evaluation and exclusive choice groups. Fabula builds on Winnow and adds metric gap constraints, cross-stage value comparison (
e2.severity > ?sev), concurrent stage groups, and thefabula-narrativescomposite scorer for MCTS integration.
Where to go next
- Pattern Cookbook -- More pattern recipes with matching and non-matching graphs
- Scoring Reference --
SurpriseScorer,StuScorer, andSequentialScorerAPI - Narrative Scoring Reference -- Thread tracking, tension arcs, pivot detection, composite scorer
- Incremental Integration -- Wire fabula into a live simulation loop