Skip to main content

6. Speculate with MCTS

Chapter 5 scored completed matches. Now you will fork the engine to ask "what if?" before committing to an action. Clone the engine, add a hypothetical event to a separate graph, score the result, and compare alternatives. The original engine is never touched.

How forking works

engine.clone() copies all patterns, partial matches, enabled flags, completion counts, and lifecycle metrics. Tick accumulators are intentionally left empty -- the forked engine starts with a clean slate for its speculative timeline.

Pair the cloned engine with a cloned MemGraph. Add your hypothetical event to the cloned graph, call on_edge_added() on the cloned engine, and score what happens. The original engine and graph are unaffected.

Complete code

/// Fork the engine, add a hypothetical event, and score the result.
/// Returns (completed pattern names, total surprise score).
fn evaluate_hypothesis(
engine: &SiftEngineFor<MemGraph>,
graph: &MemGraph,
surprise: &SurpriseScorer,
hyp_id: &str,
action: &str,
actor: &str,
stock: &str,
tick: i64,
) -> (Vec<String>, f64) {
let mut forked_engine = engine.clone();
let mut forked_graph = graph.clone();

add_event(&mut forked_graph, hyp_id, action, actor, stock, tick);

let events = forked_engine.on_edge_added(
&forked_graph,
&hyp_id.to_string(),
&"action".to_string(),
&MemValue::Str(action.to_string()),
&Interval::open(tick),
);

let completed: Vec<String> = events
.iter()
.filter_map(|e| {
if let SiftEvent::Completed { pattern, .. } = e {
Some(pattern.clone())
} else {
None
}
})
.collect();

let drained = forked_engine.drain_completed();
let scored = surprise.score(&drained, forked_engine.patterns());
let total: f64 = scored.iter().map(|s| s.surprise).sum();

(completed, total)
}

let mut graph = MemGraph::new();
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();

let idx_insider = engine.register(insider_trading_pattern());
let idx_crash = engine.register(flash_crash_pattern());
let idx_pump = engine.register(pump_dump_pattern());

let mut surprise = SurpriseScorer::new();
surprise.set_baseline(idx_insider, 0.1);
surprise.set_baseline(idx_crash, 0.05);
surprise.set_baseline(idx_pump, 0.15);

// -- run setup: build state up to a decision point (tick 4) --
let setup: Vec<Vec<(&str, &str, &str)>> = vec![
vec![("insider_tip", "alice", "ACME"), ("trade", "carol", "ACME")],
vec![
("promote", "carol", "ACME"),
("price_change", "market", "ACME"),
],
vec![("alert", "system", "ACME")],
vec![("insider_tip", "bob", "ZINC")],
];

let mut event_id = 0;
for (tick_idx, tick_events) in setup.iter().enumerate() {
let tick = (tick_idx + 1) as i64;
for &(action, actor, stock) in tick_events {
let id = format!("ev{}", event_id);
add_event(&mut graph, &id, action, actor, stock, tick);
let events = engine.on_edge_added(
&graph,
&id,
&"action".to_string(),
&MemValue::Str(action.to_string()),
&Interval::open(tick),
);
surprise.observe_events(&events, engine.patterns());
event_id += 1;
}
surprise.tick();
engine.end_tick(50);
engine.drain_completed();
}

println!("=== state at decision point (tick 4) ===");
println!("active partial matches: {}", engine.partial_matches().len());
println!("tick counter: {}", engine.current_tick());

// -- define 3 hypothetical actions at tick 5 --
let hypotheses: Vec<(&str, &str, &str, &str)> = vec![
("A: alice trades ACME", "trade", "alice", "ACME"),
("B: bob trades ZINC", "trade", "bob", "ZINC"),
("C: carol sells ACME", "sell", "carol", "ACME"),
];

// -- fork, evaluate, score each --
println!("\n=== speculative evaluation (tick 5) ===");
let mut results: Vec<(&str, Vec<String>, f64)> = Vec::new();

for (label, action, actor, stock) in &hypotheses {
let (completed, score) =
evaluate_hypothesis(&engine, &graph, &surprise, "hyp", action, actor, stock, 5);
println!("{}", label);
if completed.is_empty() {
println!(" no completions");
} else {
for p in &completed {
println!(" completed: {}", p);
}
}
println!(" surprise: {:.2} bits", score);
results.push((label, completed, score));
}

// -- pick the most surprising --
results.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
println!("\nbest hypothesis: {}", results[0].0);

// -- verify original engine is untouched --
println!("\n=== original engine (unchanged) ===");
println!("active partial matches: {}", engine.partial_matches().len());
println!("tick counter: {}", engine.current_tick());
let stale = engine.drain_completed();
println!("completed in original: {}", stale.len());

Expected output

=== state at decision point (tick 4) ===
active partial matches: ...
tick counter: 4

=== speculative evaluation (tick 5) ===
A: alice trades ACME
completed: insider_trading
surprise: ... bits
B: bob trades ZINC
completed: insider_trading
surprise: ... bits
C: carol sells ACME
completed: pump_and_dump
surprise: ... bits

best hypothesis: ...

=== original engine (unchanged) ===
active partial matches: ...
tick counter: 4
completed in original: 0

Key outcomes:

  • Hypothesis A (alice trades ACME): the insider_trading pattern was negated by the ACME alert at tick 3, so this may not complete -- or it starts a fresh match depending on engine state. The exact behavior depends on whether the negation killed all active PMs for alice/ACME.
  • Hypothesis B (bob trades ZINC): completes insider_trading for bob/ZINC (tipped at tick 4, no alert on ZINC).
  • Hypothesis C (carol sells ACME): completes pump_and_dump (carol bought at tick 1, promoted at tick 2, sells now).
  • The original engine has the same number of active partial matches and tick counter as before forking. Zero completions in the original.

Connection to MCTS

This fork-speculate-score loop is the core of Monte Carlo Tree Search for narrative generation:

  1. Select a decision point (the simulation reaches a moment where multiple actions are possible).
  2. Expand by cloning the engine once per candidate action.
  3. Simulate by calling on_edge_added() on each fork with the hypothetical event.
  4. Evaluate with the scoring pipeline (SurpriseScorer, StuScorer, SequentialScorer, or a composite NarrativeScore from fabula-narratives).
  5. Backpropagate by choosing the best-scoring action and committing it to the real engine.

Each hypothesis is a branch in the search tree. The surprise score is the evaluation function. engine.clone() is cheap -- it copies patterns, partial matches, and counters, not the graph. The graph is your simulation state; fork it however your simulation layer requires.

For deeper tree search, nest the loop: commit the best action to a fork, advance its simulation, fork again at the next decision point. The engine's Clone is designed for exactly this pattern.

What you learned

This chapter and the full tutorial:

  • Chapter 1 -- Built a simulation loop producing timestamped events into a MemGraph.
  • Chapter 2 -- Defined patterns with PatternBuilder (insider trading with negation, flash crash with concurrent group, pump-and-dump sequence) and the DSL.
  • Chapter 3 -- Wired the engine into the loop with on_edge_added() for incremental matching. Learned the 4-phase algorithm.
  • Chapter 4 -- Handled SiftEvent variants, drained completed matches, used deadlines for expiry, and ran gap analysis.
  • Chapter 5 -- Scored matches with SurpriseScorer (pattern-level), StuScorer (property-level, ArithmeticMean vs TfIdf), and SequentialScorer (transition-level).
  • Chapter 6 -- Forked the engine with clone(), evaluated hypothetical actions on separate graphs, scored and compared results, confirmed the original was untouched.

Where to go next

  • How the Engine Works -- The 4-phase algorithm, forking, deduplication, partial match lifecycle.
  • Scoring and Surprise -- Information theory foundations for the scoring pipeline.
  • DSL Reference -- Complete syntax reference for patterns, graphs, and compose operators.
  • fabula-narratives -- Composite NarrativeScore combining thread tracking (MICE), tension arcs, pivot detection, and the surprise scorers from this tutorial into a single evaluation function for MCTS.