Skip to main content

Narrative Scoring

fabula_narratives -- narrative scoring and thread management for MCTS evaluation. Provides the GM's quality function: scoring signals that tell the search whether a candidate action improves the narrative.

Four modules, each backed by specific research:

ModuleMeasuresResearch
threadThread lifecycle, FILO nestingKowal MICE Quotient
tensionNumeric trajectory (rising/falling/plateau/peak/valley)Booth 2009 (L4D AI Director), Ely/Frankel/Kamenica 2015
pivotEvent distribution shift (JSD)Schulz et al. 2024 (Narrative Information Theory)
scorerComposite quality functionNelson & Mateas 2005 (Search-Based Drama Management)

Thread Tracking

ThreadTracker

Tracks narrative thread lifecycles (MICE-style open/close pairs). Register threads, then query status after each tick.

let mut tracker = ThreadTracker::new();
tracker.register("investigation", 0, 1);

// Simulate thread lifecycle
tracker.record_open("investigation");
let violations = tracker.check_filo();

ThreadTracker::new

pub fn new() -> Self

register

Register a narrative thread with its open and close pattern indices. If using observe_delta, pattern names must follow the convention {name}_open and {name}_close.

pub fn register(&mut self, name: impl Into<String>, open_pattern_idx: usize, close_pattern_idx: usize)
ParameterTypeDescription
nameimpl Into<String>Thread name (e.g., "investigation").
open_pattern_idxusizePattern index for the opening event.
close_pattern_idxusizePattern index for the closing event.

record_open

Record that a thread opened. Deduplicates by name.

pub fn record_open(&mut self, thread_name: &str)

record_close

Record that a thread closed.

pub fn record_close(&mut self, thread_name: &str)

observe_delta

Update from a TickDelta -- automatically records opens (from delta.advanced) and closes (from delta.completed) matching the {name}_open / {name}_close convention. Ignores delta.negated, delta.expired, and delta.stalled.

pub fn observe_delta(&mut self, delta: &TickDelta)

status

Status of all registered threads. Accepts a closure returning PatternMetrics for a pattern index. Decoupled from SiftEngine for use during MCTS rollouts.

pub fn status(&self, metrics_fn: impl Fn(usize) -> Option<PatternMetrics>) -> Vec<ThreadStatus>

unresolved_thread_count

Count of threads with opens but no corresponding closes.

pub fn unresolved_thread_count(&self, metrics_fn: impl Fn(usize) -> Option<PatternMetrics>) -> usize

check_filo

Check FILO nesting: threads should close in reverse order of opening. Returns violations.

pub fn check_filo(&self) -> Vec<FiloViolation>

reset

Reset tracking state (keeps thread registrations).

pub fn reset(&mut self)

ThreadStatus

FieldTypeDescription
nameStringThread name.
open_countusizeActive open instances.
close_countu64Times the close pattern completed.
unresolvedboolHas opens without corresponding closes.

FiloViolation

FieldTypeDescription
closed_threadStringThread that closed out of order.
blocking_threadStringThread that should have closed first (opened later, still open).

Tension Tracking

TensionTracker

Tracks a numeric value over a sliding window and classifies the trajectory. The caller provides samples (e.g., character stress, faction hostility) -- the tracker is DataSource-agnostic.

let mut tracker = TensionTracker::new(10); // window of 10 samples
for i in 0..15 {
tracker.push(i as u64, i as f64 * 0.1);
}
assert_eq!(tracker.trajectory(), Trajectory::Rising);
assert!(tracker.slope() > 0.0);

TensionTracker::new

Create a tracker with the given sliding window size (minimum 3).

pub fn new(window_size: usize) -> Self

Panics if window_size < 3.


with_threshold

Create a tracker with a custom slope threshold for trajectory classification. Higher values require stronger trends to classify as Rising/Falling.

pub fn with_threshold(window_size: usize, threshold: f64) -> Self
ParameterTypeDefaultDescription
window_sizeusize--Sliding window size (minimum 3).
thresholdf640.01Slope magnitude below which trajectory is Plateau.

push

Push a new sample. Old samples outside the window are dropped.

pub fn push(&mut self, tick: u64, value: f64)

current

Most recent sample value.

pub fn current(&self) -> Option<f64>

slope

Linear regression slope over the window. Positive = rising, negative = falling, near-zero = plateau.

pub fn slope(&self) -> f64

trajectory

Classify the trajectory over the window.

pub fn trajectory(&self) -> Trajectory

Returns Unknown with fewer than 3 samples.


sample_count

Number of samples currently in the window.

pub fn sample_count(&self) -> usize

reset

Clear all samples.

pub fn reset(&mut self)

Trajectory

Classification of a trajectory's recent behavior.

VariantDescription
RisingValues increasing over the window.
FallingValues decreasing over the window.
PlateauValues approximately constant (slope below threshold).
PeakRose then fell (local maximum).
ValleyFell then rose (local minimum).
UnknownNot enough data to classify.

Trait implementations: Debug, Clone, Copy, PartialEq, Eq.


Pivot Detection

PivotDetector

Detects narrative pivots via Jensen-Shannon Divergence between consecutive tick event-type distributions. High JSD = dramatic turn; low JSD = continuation.

JSD is symmetric and bounded in [0, 1] (log base 2).

let mut pivot = PivotDetector::new();

// Tick 1: peaceful events
pivot.push("trade");
pivot.push("trade");
pivot.push("talk");
let _ = pivot.end_tick(); // first tick: 0 (no previous)

// Tick 2: sudden violence
pivot.push("attack");
pivot.push("attack");
pivot.push("harm");
let jsd = pivot.end_tick();
assert!(jsd > 0.5); // dramatic shift

PivotDetector::new

pub fn new() -> Self

push

Record an event type for the current tick.

pub fn push(&mut self, event_type: &str)

end_tick

End the current tick: compute JSD against previous tick's distribution, save current as previous, clear accumulators. Returns JSD in [0, 1]. First tick returns 0.0. Empty ticks return 0.0 and leave the previous distribution unchanged.

pub fn end_tick(&mut self) -> f64

last_pivot

Most recent JSD value.

pub fn last_pivot(&self) -> f64

average_pivot

Average pivot magnitude over the last N ticks. Returns 0.0 if history is empty or window is 0.

pub fn average_pivot(&self, window: usize) -> f64

history

Full history of JSD values.

pub fn history(&self) -> &[f64]

reset

Reset all state.

pub fn reset(&mut self)

Composite Scorer

score

Pure function: signals in, score out. Combines multiple scoring signals into a single NarrativeScore using configurable weights.

let signals = NarrativeSignals {
advancements: 3,
completions: 1,
resolutions: 1,
pivot_magnitude: 0.4,
..Default::default()
};
let result = score(&signals, &NarrativeWeights::default());
assert!(result.total > 0.0);
println!("Breakdown: {:?}", result.breakdown);
pub fn score(signals: &NarrativeSignals, weights: &NarrativeWeights) -> NarrativeScore

tension_fit

Compute tension fit from a trajectory and desired direction. Returns 1.0 (match), -1.0 (opposite), or 0.0 (neutral/unknown).

pub fn tension_fit(actual: Trajectory, desired: Trajectory) -> f64

assemble_signals

Convenience function: assemble NarrativeSignals from tracker outputs and engine data.

pub fn assemble_signals(
delta: &TickDelta,
plant_statuses: &[PlantStatus],
filo_violations: usize,
tension_trajectory: Trajectory,
desired_trajectory: Trajectory,
pivot_magnitude: f64,
surprise: f64,
sequential_surprise: f64,
) -> NarrativeSignals
ParameterTypeDescription
delta&TickDeltaThis tick's delta from the engine.
plant_statuses&[PlantStatus]From engine.plant_status().
filo_violationsusizeFrom tracker.check_filo().len().
tension_trajectoryTrajectoryFrom tension.trajectory().
desired_trajectoryTrajectoryWhat the GM wants tension to do.
pivot_magnitudef64From pivot.last_pivot().
surprisef64From scorer.surprise_for() or similar.
sequential_surprisef64From SequentialScorer::score_transition().

NarrativeWeights

Configurable weights for each scoring signal. All have sensible defaults.

FieldTypeDefaultDescription
progressf641.0Reward per pattern advancement.
completionf643.0Reward per pattern completion.
stall_penaltyf64-2.0Penalty per stalled pattern.
unresolved_penaltyf64-0.5Penalty per unresolved plant.
resolution_rewardf645.0Reward per resolved plant/payoff.
filo_violation_penaltyf64-3.0Penalty per FILO nesting violation.
tension_fitf642.0Reward when tension matches desired trajectory.
pivot_rewardf641.5Reward scaled by pivot magnitude.
surprise_rewardf641.0Reward scaled by surprise score.
sequential_surprise_rewardf641.0Reward scaled by sequential surprise score.

NarrativeSignals

Input signals for the scorer. Assemble manually or use assemble_signals().

FieldTypeDescription
advancementsusizePatterns that advanced this tick.
completionsusizePatterns that completed this tick.
stalledusizeStalled patterns.
unresolved_plantsusizeUnresolved plant setups.
resolutionsusizePlant/payoff pairs resolved this tick.
filo_violationsusizeThread nesting violations.
tension_fitf641.0 (match), -1.0 (opposite), 0.0 (neutral).
pivot_magnitudef64JSD from PivotDetector (0-1).
surprisef64Pattern-level surprise.
sequential_surprisef64Sequential transition surprise (from SequentialScorer).

Trait implementations: Debug, Clone, Default.


NarrativeScore

Composite score with explainable breakdown.

FieldTypeDescription
totalf64Overall quality score (higher = better).
breakdownScoreBreakdownPer-signal contributions.

ScoreBreakdown

Per-signal contribution to the total score.

FieldTypeDescription
progressf64From advancements * weight.
completionf64From completions * weight.
stall_penaltyf64From stalled * weight (negative).
unresolved_penaltyf64From unresolved plants * weight (negative).
resolutionf64From resolutions * weight.
filo_penaltyf64From violations * weight (negative).
tensionf64From tension_fit * weight.
pivotf64From pivot_magnitude * weight.
surprisef64From surprise * weight.
sequential_surprisef64From sequential_surprise * weight.

Trait implementations: Debug, Clone, Default.