Skip to main content

Forking for MCTS

Learning objective: Clone the engine for speculative evaluation, score hypothetical outcomes, and select the best action.

Prerequisites

Step 1: Clone the engine

SiftEngine implements a manual Clone that copies all matching state -- patterns, partial matches, enabled flags, stats, tick counter -- but intentionally resets tick accumulators to empty. The forked engine starts with a clean slate for its first tick.

let mut fork = engine.clone();

The clone is fully independent. Advancing or mutating the clone has no effect on the original.

Step 2: Fork the data source

Create a fresh graph (or clone one) for the speculative branch. The forked graph receives hypothetical events that may never happen in the real simulation.

let mut fork_graph = graph.clone();

MemGraph derives Clone, so this copies all existing edges. If your data source does not support clone, construct a new one and replay the relevant edges.

Step 3: Speculate

Add hypothetical edges to the forked graph and feed them to the cloned engine.

fork_graph.add_str("hyp1", "eventType", "betray", 100);
fork_graph.add_ref("hyp1", "actor", "bob", 100);
fork_graph.add_ref("hyp1", "target", "alice", 100);
fork_graph.set_time(100);

let events = fork.on_edge_added(
&fork_graph,
&"hyp1".into(),
&"eventType".into(),
&MemValue::Str("betray".into()),
&Interval::open(100),
);

The returned Vec<SiftEvent> tells you what happened: which patterns advanced, completed, or were negated by this hypothetical edge.

Step 4: Score

Call end_tick() on the fork to get a TickDelta, then assemble signals and score.

let (delta, _expired) = fork.end_tick(50);

let signals = assemble_signals(
&delta,
&fork.plant_status(50),
0, // filo violations
Trajectory::Unknown, // tension trajectory
Trajectory::Rising, // desired trajectory
0.0, // pivot magnitude
0.0, // surprise
0.0, // sequential surprise
);
let result = score(&signals, &NarrativeWeights::default());
// result.total is the composite narrative quality score

Step 5: Compare candidates

Evaluate multiple hypothetical actions, each on its own fork, and pick the best.

let candidates = vec![
("betray", "bob", "alice"),
("forgive", "bob", "alice"),
("conspire", "bob", "charlie"),
];

let mut best_score = f64::NEG_INFINITY;
let mut best_action = "";

for (action, actor, target) in &candidates {
let mut fork = engine.clone();
let mut fork_graph = graph.clone();

fork_graph.add_str("hyp", "eventType", action, 100);
fork_graph.add_ref("hyp", "actor", actor, 100);
fork_graph.add_ref("hyp", "target", target, 100);
fork_graph.set_time(100);

fork.on_edge_added(
&fork_graph,
&"hyp".into(),
&"eventType".into(),
&MemValue::Str(action.to_string()),
&Interval::open(100),
);

let (delta, _) = fork.end_tick(50);
let signals = assemble_signals(
&delta,
&fork.plant_status(50),
0, Trajectory::Unknown, Trajectory::Rising, 0.0, 0.0, 0.0,
);
let result = score(&signals, &NarrativeWeights::default());

if result.total > best_score {
best_score = result.total;
best_action = action;
}
}
// best_action is the narratively strongest candidate

Step 6: Discard

When a fork goes out of scope, Rust drops it. No cleanup, no deregistration. The original engine and graph are untouched.

{
let fork = engine.clone();
let fork_graph = graph.clone();
// ... speculate, score ...
} // fork and fork_graph dropped here

Minimal example

Before the full setup, here is the smallest possible fork-speculate-compare loop. One pattern, two candidate actions, no narrative scorer -- just raw match counts.

use fabula::prelude::*;
use fabula_memory::{MemGraph, MemValue};

fn main() {
let mut engine: SiftEngineFor<MemGraph> = SiftEngine::new();

// A simple 2-stage "betrayal" pattern: promise then betray, same actor.
engine.register(
PatternBuilder::new("betrayal")
.stage("e1", |s| s
.edge("e1", "eventType".into(), MemValue::Str("promise".into()))
.edge_bind("e1", "actor".into(), "char"))
.stage("e2", |s| s
.edge("e2", "eventType".into(), MemValue::Str("betray".into()))
.edge_bind("e2", "actor".into(), "char"))
.build(),
);

// Simulate: Alice promises at tick 1.
let mut graph = MemGraph::new();
graph.add_str("ev1", "eventType", "promise", 1);
graph.add_ref("ev1", "actor", "alice", 1);
graph.set_time(1);
engine.on_edge_added(
&graph, &"ev1".into(), &"eventType".into(),
&MemValue::Str("promise".into()), &Interval::open(1),
);
engine.end_tick(50);
// Engine now has an active partial match waiting for Alice to betray.

// Candidate A: Alice betrays (should complete the pattern).
let mut fork_a = engine.clone();
let mut graph_a = graph.clone();
graph_a.add_str("hyp", "eventType", "betray", 2);
graph_a.add_ref("hyp", "actor", "alice", 2);
graph_a.set_time(2);
fork_a.on_edge_added(
&graph_a, &"hyp".into(), &"eventType".into(),
&MemValue::Str("betray".into()), &Interval::open(2),
);
let (delta_a, _) = fork_a.end_tick(50);

// Candidate B: Alice trades (no pattern effect).
let mut fork_b = engine.clone();
let mut graph_b = graph.clone();
graph_b.add_str("hyp", "eventType", "trade", 2);
graph_b.add_ref("hyp", "actor", "alice", 2);
graph_b.set_time(2);
fork_b.on_edge_added(
&graph_b, &"hyp".into(), &"eventType".into(),
&MemValue::Str("trade".into()), &Interval::open(2),
);
let (delta_b, _) = fork_b.end_tick(50);

println!("Betray: {} completions", delta_a.completed.len()); // 1
println!("Trade: {} completions", delta_b.completed.len()); // 0
// The original engine is untouched -- both forks are discarded.
}

The key insight: engine.clone() gives you independent speculative branches. Compare their TickDelta output to decide which action is more narratively productive.

Complete example

A single test that sets up two patterns, runs a short simulation, then evaluates three candidate actions and selects the best.

let (engine, graph) = setup_base();

// -- Fork-speculate-score loop --------------------------------------
let candidates: Vec<(&str, &str, &str)> = vec![
("harm", "bob", "alice"), // completes hospitality_violation
("forgive", "alice", "bob"), // no pattern effect (no prior harm)
("trade", "bob", "alice"), // neutral — advances nothing
];

let weights = NarrativeWeights::default();
let mut best_score = f64::NEG_INFINITY;
let mut best_action = "";

for (action, actor, target) in &candidates {
// Fork
let mut fork = engine.clone();
let mut fork_graph = graph.clone();

// Speculate
fork_graph.add_str("hyp", "eventType", action, 3);
fork_graph.add_ref("hyp", "actor", actor, 3);
fork_graph.add_ref("hyp", "target", target, 3);
fork_graph.set_time(3);

fork.on_edge_added(
&fork_graph,
&"hyp".into(),
&"eventType".into(),
&MemValue::Str(action.to_string()),
&Interval::open(3),
);

// Score
let (delta, _) = fork.end_tick(50);
let signals = assemble_signals(
&delta,
&fork.plant_status(50),
0,
Trajectory::Unknown,
Trajectory::Rising,
0.0,
0.0,
0.0,
);
let result = score(&signals, &weights);

println!(
"Action: {:<10} score: {:.2} (adv={}, comp={}, stall={})",
action,
result.total,
delta.advanced.len(),
delta.completed.len(),
delta.stalled.len(),
);

if result.total > best_score {
best_score = result.total;
best_action = action;
}
// fork and fork_graph are dropped here
}

println!("\nBest action: {} (score: {:.2})", best_action, best_score);
// The original engine is unchanged — no speculative state leaked.
assert_eq!(
engine.partial_matches().len(),
engine
.partial_matches()
.iter()
.filter(|pm| pm.state == MatchState::Active)
.count(),
"original engine has only its original active PMs"
);

Notes

Memory. Each engine.clone() copies every partial match. If the engine has 10,000 active PMs and you evaluate 50 candidates, that is 500,000 PM copies per decision point. Profile with dhat (see fabula-bench) if this becomes a bottleneck.

Performance. The fork-speculate-score loop is embarrassingly parallel. Each fork is independent, so you can evaluate candidates across threads with no synchronization. The engine and graph are both Send.

Shallow speculation. The example above looks one action ahead. For deeper MCTS trees, nest the fork: clone the clone, speculate further, score, backpropagate. The same pattern applies at every depth.

Weights tuning. NarrativeWeights::default() is a starting point. Adjust weights per story phase -- for example, increase completion weight during a climax and progress weight during rising action.

See also