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.