Skip to main content

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
PrerequisitesWhat 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.

Loading playground...

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.

What to notice

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.

Loading playground...

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.

What to notice

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:

Loading playground...

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:

  1. Detect -- engine.on_edge_added() feeds simulation events incrementally
  2. Complete -- engine.end_tick() finalizes the tick and produces a TickDelta
  3. Score -- SurpriseScorer ranks completed matches by unexpectedness
  4. 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 fieldFabula edge
eventID (unique per event)source node
eventType (meet, betray, travel)label value
actor, target, locationtarget nodes
tick or simulation stepinterval 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 the fabula-narratives composite scorer for MCTS integration.

Where to go next