use crate::tension::Trajectory;
use fabula::engine::{PlantStatus, TickDelta};
#[derive(Debug, Clone)]
pub struct NarrativeWeights {
pub progress: f64,
pub completion: f64,
pub stall_penalty: f64,
pub unresolved_penalty: f64,
pub resolution_reward: f64,
pub filo_violation_penalty: f64,
pub tension_fit: f64,
pub pivot_reward: f64,
pub surprise_reward: f64,
pub sequential_surprise_reward: f64,
}
impl Default for NarrativeWeights {
fn default() -> Self {
Self {
progress: 1.0,
completion: 3.0,
stall_penalty: -2.0,
unresolved_penalty: -0.5,
resolution_reward: 5.0,
filo_violation_penalty: -3.0,
tension_fit: 2.0,
pivot_reward: 1.5,
surprise_reward: 1.0,
sequential_surprise_reward: 1.0,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct NarrativeSignals {
pub advancements: usize,
pub completions: usize,
pub stalled: usize,
pub unresolved_plants: usize,
pub resolutions: usize,
pub filo_violations: usize,
pub tension_fit: f64,
pub pivot_magnitude: f64,
pub surprise: f64,
pub sequential_surprise: f64,
}
#[derive(Debug, Clone)]
pub struct NarrativeScore {
pub total: f64,
pub breakdown: ScoreBreakdown,
}
#[derive(Debug, Clone, Default)]
pub struct ScoreBreakdown {
pub progress: f64,
pub completion: f64,
pub stall_penalty: f64,
pub unresolved_penalty: f64,
pub resolution: f64,
pub filo_penalty: f64,
pub tension: f64,
pub pivot: f64,
pub surprise: f64,
pub sequential_surprise: f64,
}
pub fn score(signals: &NarrativeSignals, weights: &NarrativeWeights) -> NarrativeScore {
let breakdown = ScoreBreakdown {
progress: signals.advancements as f64 * weights.progress,
completion: signals.completions as f64 * weights.completion,
stall_penalty: signals.stalled as f64 * weights.stall_penalty,
unresolved_penalty: signals.unresolved_plants as f64 * weights.unresolved_penalty,
resolution: signals.resolutions as f64 * weights.resolution_reward,
filo_penalty: signals.filo_violations as f64 * weights.filo_violation_penalty,
tension: signals.tension_fit * weights.tension_fit,
pivot: signals.pivot_magnitude * weights.pivot_reward,
surprise: signals.surprise * weights.surprise_reward,
sequential_surprise: signals.sequential_surprise * weights.sequential_surprise_reward,
};
let total = breakdown.progress
+ breakdown.completion
+ breakdown.stall_penalty
+ breakdown.unresolved_penalty
+ breakdown.resolution
+ breakdown.filo_penalty
+ breakdown.tension
+ breakdown.pivot
+ breakdown.surprise
+ breakdown.sequential_surprise;
NarrativeScore { total, breakdown }
}
pub fn tension_fit(actual: Trajectory, desired: Trajectory) -> f64 {
match (actual, desired) {
(Trajectory::Unknown, _) | (_, Trajectory::Unknown) => 0.0,
(a, d) if a == d => 1.0,
(Trajectory::Rising, Trajectory::Falling) | (Trajectory::Falling, Trajectory::Rising) => {
-1.0
}
(Trajectory::Peak, Trajectory::Valley) | (Trajectory::Valley, Trajectory::Peak) => -1.0,
_ => 0.0,
}
}
#[allow(clippy::too_many_arguments)]
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 {
NarrativeSignals {
advancements: delta.advanced.len(),
completions: delta.completed.len(),
stalled: delta.stalled.len(),
unresolved_plants: plant_statuses
.iter()
.filter(|p| p.active_plants > 0 && p.payoff_completions == 0)
.count(),
resolutions: delta
.completed
.iter()
.filter(|name| plant_statuses.iter().any(|p| &p.payoff_pattern == *name))
.count(),
filo_violations,
tension_fit: tension_fit(tension_trajectory, desired_trajectory),
pivot_magnitude,
surprise,
sequential_surprise,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn positive_progress_scores_positive() {
let signals = NarrativeSignals {
advancements: 5,
completions: 2,
..Default::default()
};
let result = score(&signals, &NarrativeWeights::default());
assert!(result.total > 0.0);
assert!(result.breakdown.progress > 0.0);
assert!(result.breakdown.completion > 0.0);
}
#[test]
fn stalled_patterns_penalize() {
let signals = NarrativeSignals {
stalled: 3,
..Default::default()
};
let result = score(&signals, &NarrativeWeights::default());
assert!(
result.total < 0.0,
"stalled patterns should produce negative score"
);
}
#[test]
fn resolution_rewards() {
let signals = NarrativeSignals {
resolutions: 2,
..Default::default()
};
let result = score(&signals, &NarrativeWeights::default());
assert_eq!(result.breakdown.resolution, 10.0); }
#[test]
fn filo_violations_penalize() {
let signals = NarrativeSignals {
filo_violations: 1,
..Default::default()
};
let result = score(&signals, &NarrativeWeights::default());
assert!(result.total < 0.0);
}
#[test]
fn tension_fit_matching() {
assert_eq!(tension_fit(Trajectory::Rising, Trajectory::Rising), 1.0);
assert_eq!(tension_fit(Trajectory::Rising, Trajectory::Falling), -1.0);
assert_eq!(tension_fit(Trajectory::Plateau, Trajectory::Rising), 0.0);
}
#[test]
fn tension_fit_unknown_returns_zero() {
assert_eq!(tension_fit(Trajectory::Unknown, Trajectory::Unknown), 0.0);
assert_eq!(tension_fit(Trajectory::Unknown, Trajectory::Rising), 0.0);
assert_eq!(tension_fit(Trajectory::Rising, Trajectory::Unknown), 0.0);
}
#[test]
fn assemble_signals_from_delta() {
let delta = TickDelta {
advanced: vec!["pattern_a".into(), "pattern_b".into()],
completed: vec!["payoff_x".into()],
negated: vec![],
expired: vec![],
stalled: vec!["stale_one".into()],
active_pm_count: 5,
};
let plants = vec![PlantStatus {
plant_pattern: "plant_x".into(),
payoff_pattern: "payoff_x".into(),
active_plants: 1,
payoff_completions: 0,
ticks_since_plant_advanced: 10,
stale: true,
}];
let signals = assemble_signals(
&delta,
&plants,
2,
Trajectory::Rising,
Trajectory::Rising,
0.5,
0.3,
1.7,
);
assert_eq!(signals.advancements, 2);
assert_eq!(signals.completions, 1);
assert_eq!(signals.stalled, 1);
assert_eq!(signals.unresolved_plants, 1);
assert_eq!(signals.resolutions, 1); assert_eq!(signals.filo_violations, 2);
assert_eq!(signals.tension_fit, 1.0); assert_eq!(signals.pivot_magnitude, 0.5);
assert_eq!(signals.surprise, 0.3);
assert_eq!(signals.sequential_surprise, 1.7);
}
#[test]
fn custom_weights() {
let signals = NarrativeSignals {
advancements: 1,
..Default::default()
};
let weights = NarrativeWeights {
progress: 100.0,
..NarrativeWeights::default()
};
let result = score(&signals, &weights);
assert_eq!(result.breakdown.progress, 100.0);
}
#[test]
fn zero_signals_zero_score() {
let result = score(&NarrativeSignals::default(), &NarrativeWeights::default());
assert_eq!(result.total, 0.0);
}
}