use serde::{Deserialize, Serialize};
use crate::state::NodeId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ArcId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ArcTheme {
Growth,
Challenge,
Relationship,
Project,
Habit,
Discovery,
Loss,
Recovery,
}
impl ArcTheme {
pub fn as_str(self) -> &'static str {
match self {
Self::Growth => "growth",
Self::Challenge => "challenge",
Self::Relationship => "relationship",
Self::Project => "project",
Self::Habit => "habit",
Self::Discovery => "discovery",
Self::Loss => "loss",
Self::Recovery => "recovery",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"growth" => Self::Growth,
"challenge" => Self::Challenge,
"relationship" => Self::Relationship,
"project" => Self::Project,
"habit" => Self::Habit,
"discovery" => Self::Discovery,
"loss" => Self::Loss,
"recovery" => Self::Recovery,
_ => Self::Project,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ArcStatus {
Emerging,
Active,
Paused,
Resolved,
Abandoned,
}
impl ArcStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Emerging => "emerging",
Self::Active => "active",
Self::Paused => "paused",
Self::Resolved => "resolved",
Self::Abandoned => "abandoned",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ChapterType {
Setup,
Rising,
Climax,
Falling,
Resolution,
Interlude,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DirectionChange {
Positive,
Negative,
Pivot,
Escalation,
DeEscalation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Chapter {
pub title: String,
pub episodes: Vec<NodeId>,
pub summary: String,
pub chapter_type: ChapterType,
pub time_span: (u64, u64),
pub sentiment_trajectory: Vec<f64>,
}
impl Chapter {
pub fn avg_sentiment(&self) -> f64 {
if self.sentiment_trajectory.is_empty() {
return 0.0;
}
self.sentiment_trajectory.iter().sum::<f64>() / self.sentiment_trajectory.len() as f64
}
pub fn duration_ms(&self) -> u64 {
self.time_span.1.saturating_sub(self.time_span.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TurningPoint {
pub episode_id: NodeId,
pub description: String,
pub direction_change: DirectionChange,
pub magnitude: f64,
pub timestamp_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NarrativeArc {
pub id: ArcId,
pub title: String,
pub theme: ArcTheme,
pub chapters: Vec<Chapter>,
pub participants: Vec<NodeId>,
pub domains: Vec<String>,
pub status: ArcStatus,
pub emotional_valence: f64,
pub started_at: u64,
pub last_updated_at: u64,
pub turning_points: Vec<TurningPoint>,
}
impl NarrativeArc {
pub fn episode_count(&self) -> usize {
self.chapters.iter().map(|c| c.episodes.len()).sum()
}
pub fn all_episodes(&self) -> Vec<NodeId> {
self.chapters
.iter()
.flat_map(|c| c.episodes.iter().copied())
.collect()
}
pub fn duration_ms(&self) -> u64 {
if self.chapters.is_empty() {
return 0;
}
let start = self.chapters.first().map(|c| c.time_span.0).unwrap_or(0);
let end = self.chapters.last().map(|c| c.time_span.1).unwrap_or(0);
end.saturating_sub(start)
}
pub fn is_active(&self) -> bool {
matches!(self.status, ArcStatus::Emerging | ArcStatus::Active)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Milestone {
pub timestamp_ms: u64,
pub description: String,
pub impact_domains: Vec<String>,
pub related_arcs: Vec<ArcId>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutobiographicalTimeline {
pub arcs: Vec<NarrativeArc>,
pub milestones: Vec<Milestone>,
next_arc_id: u64,
}
impl Default for AutobiographicalTimeline {
fn default() -> Self {
Self {
arcs: Vec::new(),
milestones: Vec::new(),
next_arc_id: 1,
}
}
}
impl AutobiographicalTimeline {
pub fn alloc_arc_id(&mut self) -> ArcId {
let id = ArcId(self.next_arc_id);
self.next_arc_id += 1;
id
}
pub fn find_arc(&self, id: ArcId) -> Option<&NarrativeArc> {
self.arcs.iter().find(|a| a.id == id)
}
pub fn find_arc_mut(&mut self, id: ArcId) -> Option<&mut NarrativeArc> {
self.arcs.iter_mut().find(|a| a.id == id)
}
pub fn active_arcs(&self) -> Vec<&NarrativeArc> {
self.arcs.iter().filter(|a| a.is_active()).collect()
}
pub fn unresolved_arcs(&self) -> Vec<&NarrativeArc> {
self.arcs
.iter()
.filter(|a| !matches!(a.status, ArcStatus::Resolved | ArcStatus::Abandoned))
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NarrativeEpisode {
pub episode_id: NodeId,
pub summary: String,
pub participants: Vec<NodeId>,
pub domains: Vec<String>,
pub sentiment: f64,
pub timestamp_ms: u64,
pub related_goal: Option<NodeId>,
}
pub fn assign_to_arc(episode: &NarrativeEpisode, timeline: &mut AutobiographicalTimeline) -> ArcId {
let mut best_arc: Option<(ArcId, f64)> = None;
for arc in &timeline.arcs {
if !arc.is_active() {
continue;
}
let score = arc_match_score(episode, arc);
if score > 0.3 {
if best_arc.is_none() || score > best_arc.unwrap().1 {
best_arc = Some((arc.id, score));
}
}
}
if let Some((arc_id, _)) = best_arc {
add_episode_to_arc(episode, arc_id, timeline);
arc_id
} else {
create_arc_from_episode(episode, timeline)
}
}
fn arc_match_score(episode: &NarrativeEpisode, arc: &NarrativeArc) -> f64 {
let participant_overlap = if arc.participants.is_empty() || episode.participants.is_empty() {
0.0
} else {
let shared = episode
.participants
.iter()
.filter(|p| arc.participants.contains(p))
.count();
shared as f64 / episode.participants.len().max(1) as f64
};
let domain_overlap = if arc.domains.is_empty() || episode.domains.is_empty() {
0.0
} else {
let shared = episode
.domains
.iter()
.filter(|d| arc.domains.contains(d))
.count();
shared as f64 / episode.domains.len().max(1) as f64
};
let age_ms = episode.timestamp_ms.saturating_sub(arc.last_updated_at);
let age_days = age_ms as f64 / 86_400_000.0;
let recency = (-age_days / 14.0).exp();
let sentiment_diff = (episode.sentiment - arc.emotional_valence).abs();
let sentiment_cont = 1.0 - (sentiment_diff / 2.0);
0.40 * participant_overlap + 0.30 * domain_overlap + 0.15 * recency + 0.15 * sentiment_cont
}
fn add_episode_to_arc(
episode: &NarrativeEpisode,
arc_id: ArcId,
timeline: &mut AutobiographicalTimeline,
) {
if let Some(arc) = timeline.find_arc_mut(arc_id) {
let needs_new_chapter = if let Some(last_chapter) = arc.chapters.last() {
detect_chapter_boundary_internal(last_chapter, episode)
} else {
true
};
if needs_new_chapter {
let chapter_num = arc.chapters.len() + 1;
arc.chapters.push(Chapter {
title: format!("Chapter {}", chapter_num),
episodes: vec![episode.episode_id],
summary: episode.summary.clone(),
chapter_type: infer_chapter_type(chapter_num, arc.status),
time_span: (episode.timestamp_ms, episode.timestamp_ms),
sentiment_trajectory: vec![episode.sentiment],
});
} else if let Some(chapter) = arc.chapters.last_mut() {
chapter.episodes.push(episode.episode_id);
chapter.time_span.1 = episode.timestamp_ms;
chapter.sentiment_trajectory.push(episode.sentiment);
}
if let Some(tp) = detect_turning_point_internal(arc, episode) {
arc.turning_points.push(tp);
}
arc.last_updated_at = episode.timestamp_ms;
arc.emotional_valence = 0.8 * arc.emotional_valence + 0.2 * episode.sentiment;
for p in &episode.participants {
if !arc.participants.contains(p) {
arc.participants.push(*p);
}
}
for d in &episode.domains {
if !arc.domains.contains(d) {
arc.domains.push(d.clone());
}
}
if arc.status == ArcStatus::Emerging && arc.episode_count() >= 3 {
arc.status = ArcStatus::Active;
}
}
}
fn create_arc_from_episode(
episode: &NarrativeEpisode,
timeline: &mut AutobiographicalTimeline,
) -> ArcId {
let arc_id = timeline.alloc_arc_id();
let theme = infer_theme_from_episode(episode);
let arc = NarrativeArc {
id: arc_id,
title: format!("{}: {}", theme.as_str(), truncate(&episode.summary, 40)),
theme,
chapters: vec![Chapter {
title: "Chapter 1".to_string(),
episodes: vec![episode.episode_id],
summary: episode.summary.clone(),
chapter_type: ChapterType::Setup,
time_span: (episode.timestamp_ms, episode.timestamp_ms),
sentiment_trajectory: vec![episode.sentiment],
}],
participants: episode.participants.clone(),
domains: episode.domains.clone(),
status: ArcStatus::Emerging,
emotional_valence: episode.sentiment,
started_at: episode.timestamp_ms,
last_updated_at: episode.timestamp_ms,
turning_points: Vec::new(),
};
timeline.arcs.push(arc);
arc_id
}
pub fn detect_chapter_boundary(arc: &NarrativeArc, episode: &NarrativeEpisode) -> bool {
if let Some(last_chapter) = arc.chapters.last() {
detect_chapter_boundary_internal(last_chapter, episode)
} else {
true
}
}
fn detect_chapter_boundary_internal(last_chapter: &Chapter, episode: &NarrativeEpisode) -> bool {
let time_gap = episode
.timestamp_ms
.saturating_sub(last_chapter.time_span.1);
if time_gap > 48 * 3600 * 1000 {
return true;
}
let avg = last_chapter.avg_sentiment();
let diff = (episode.sentiment - avg).abs();
if diff > 0.6 {
return true;
}
if last_chapter.episodes.len() >= 10 {
return true;
}
false
}
pub fn detect_turning_point(
arc: &NarrativeArc,
episode: &NarrativeEpisode,
) -> Option<TurningPoint> {
detect_turning_point_internal(arc, episode)
}
fn detect_turning_point_internal(
arc: &NarrativeArc,
episode: &NarrativeEpisode,
) -> Option<TurningPoint> {
let sentiment_delta = episode.sentiment - arc.emotional_valence;
let magnitude = sentiment_delta.abs();
if magnitude < 0.4 {
return None; }
let direction = if sentiment_delta > 0.0 && episode.sentiment > 0.3 {
DirectionChange::Positive
} else if sentiment_delta < 0.0 && episode.sentiment < -0.3 {
DirectionChange::Negative
} else if magnitude > 0.7 {
DirectionChange::Pivot
} else if sentiment_delta > 0.0 {
DirectionChange::DeEscalation
} else {
DirectionChange::Escalation
};
Some(TurningPoint {
episode_id: episode.episode_id,
description: format!(
"Sentiment shifted {:.1} (from {:.1} to {:.1})",
sentiment_delta, arc.emotional_valence, episode.sentiment,
),
direction_change: direction,
magnitude: magnitude.min(1.0),
timestamp_ms: episode.timestamp_ms,
})
}
pub fn detect_arc_resolution(arc: &NarrativeArc, now_ms: u64) -> bool {
let recent_sentiments: Vec<f64> = arc
.chapters
.iter()
.flat_map(|c| c.sentiment_trajectory.iter())
.copied()
.rev()
.take(3)
.collect();
if recent_sentiments.len() >= 3 && recent_sentiments.iter().all(|&s| s > 0.5) {
return true;
}
let age = now_ms.saturating_sub(arc.last_updated_at);
if age > 30 * 86_400_000 {
return true;
}
false
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArcAlert {
pub arc_id: ArcId,
pub arc_title: String,
pub alert_type: ArcAlertType,
pub severity: f64,
pub description: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ArcAlertType {
Stalled,
TrendingNegative,
AbandonedUnresolved,
}
pub fn arc_health_check(timeline: &AutobiographicalTimeline, now_ms: u64) -> Vec<ArcAlert> {
let mut alerts = Vec::new();
for arc in &timeline.arcs {
if matches!(arc.status, ArcStatus::Resolved | ArcStatus::Abandoned) {
continue;
}
let age = now_ms.saturating_sub(arc.last_updated_at);
if age > 14 * 86_400_000 {
alerts.push(ArcAlert {
arc_id: arc.id,
arc_title: arc.title.clone(),
alert_type: ArcAlertType::Stalled,
severity: (age as f64 / (30.0 * 86_400_000.0)).min(1.0),
description: format!(
"Arc '{}' has had no episodes for {:.0} days",
arc.title,
age as f64 / 86_400_000.0,
),
});
}
let recent: Vec<f64> = arc
.chapters
.iter()
.flat_map(|c| c.sentiment_trajectory.iter())
.copied()
.rev()
.take(3)
.collect();
if recent.len() >= 3 && recent.iter().all(|&s| s < -0.2) {
let avg = recent.iter().sum::<f64>() / recent.len() as f64;
alerts.push(ArcAlert {
arc_id: arc.id,
arc_title: arc.title.clone(),
alert_type: ArcAlertType::TrendingNegative,
severity: (-avg).min(1.0),
description: format!(
"Arc '{}' sentiment trending negative (avg: {:.2})",
arc.title, avg,
),
});
}
}
alerts
}
pub fn merge_arcs(a: &NarrativeArc, b: &NarrativeArc) -> NarrativeArc {
let (first, second) = if a.started_at <= b.started_at {
(a, b)
} else {
(b, a)
};
let mut chapters = first.chapters.clone();
chapters.extend(second.chapters.iter().cloned());
chapters.sort_by_key(|c| c.time_span.0);
let mut participants = first.participants.clone();
for p in &second.participants {
if !participants.contains(p) {
participants.push(*p);
}
}
let mut domains = first.domains.clone();
for d in &second.domains {
if !domains.contains(d) {
domains.push(d.clone());
}
}
let mut turning_points = first.turning_points.clone();
turning_points.extend(second.turning_points.iter().cloned());
turning_points.sort_by_key(|tp| tp.timestamp_ms);
let now = second.last_updated_at.max(first.last_updated_at);
NarrativeArc {
id: first.id, title: format!("{} + {}", first.title, second.title),
theme: first.theme, chapters,
participants,
domains,
status: if first.is_active() || second.is_active() {
ArcStatus::Active
} else {
first.status
},
emotional_valence: (first.emotional_valence + second.emotional_valence) / 2.0,
started_at: first.started_at,
last_updated_at: now,
turning_points,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum NarrativeQuery {
ArcsByParticipant(NodeId),
ArcsByDomain(String),
ArcsByTheme(ArcTheme),
ActiveArcs,
UnresolvedThreads,
TimeRange(u64, u64),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NarrativeResult {
pub arcs: Vec<ArcId>,
pub total_episodes: usize,
pub summary: String,
}
pub fn query_timeline(
timeline: &AutobiographicalTimeline,
query: &NarrativeQuery,
) -> NarrativeResult {
let matching: Vec<&NarrativeArc> = match query {
NarrativeQuery::ArcsByParticipant(pid) => timeline
.arcs
.iter()
.filter(|a| a.participants.contains(pid))
.collect(),
NarrativeQuery::ArcsByDomain(domain) => timeline
.arcs
.iter()
.filter(|a| a.domains.contains(domain))
.collect(),
NarrativeQuery::ArcsByTheme(theme) => {
timeline.arcs.iter().filter(|a| a.theme == *theme).collect()
}
NarrativeQuery::ActiveArcs => timeline.arcs.iter().filter(|a| a.is_active()).collect(),
NarrativeQuery::UnresolvedThreads => timeline
.arcs
.iter()
.filter(|a| !matches!(a.status, ArcStatus::Resolved | ArcStatus::Abandoned))
.collect(),
NarrativeQuery::TimeRange(start, end) => timeline
.arcs
.iter()
.filter(|a| a.started_at <= *end && a.last_updated_at >= *start)
.collect(),
};
let total_episodes: usize = matching.iter().map(|a| a.episode_count()).sum();
let arc_ids: Vec<ArcId> = matching.iter().map(|a| a.id).collect();
let titles: Vec<&str> = matching.iter().map(|a| a.title.as_str()).collect();
NarrativeResult {
arcs: arc_ids,
total_episodes,
summary: if titles.is_empty() {
"No matching arcs found".to_string()
} else {
format!("{} arcs: {}", titles.len(), titles.join(", "))
},
}
}
pub fn generate_arc_summary(arc: &NarrativeArc) -> String {
let duration_days = arc.duration_ms() as f64 / 86_400_000.0;
let episode_count = arc.episode_count();
let chapter_count = arc.chapters.len();
let tp_count = arc.turning_points.len();
let sentiment_desc = if arc.emotional_valence > 0.3 {
"positive"
} else if arc.emotional_valence < -0.3 {
"challenging"
} else {
"neutral"
};
format!(
"'{}' ({}, {}): {} episodes across {} chapters over {:.0} days. \
{} turning points. Overall tone: {} ({:.2}). Status: {}.",
arc.title,
arc.theme.as_str(),
arc.status.as_str(),
episode_count,
chapter_count,
duration_days,
tp_count,
sentiment_desc,
arc.emotional_valence,
arc.status.as_str(),
)
}
fn truncate(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len.saturating_sub(3)])
}
}
fn infer_theme_from_episode(episode: &NarrativeEpisode) -> ArcTheme {
for domain in &episode.domains {
let d = domain.to_lowercase();
if d.contains("learn") || d.contains("skill") || d.contains("study") {
return ArcTheme::Growth;
}
if d.contains("project") || d.contains("work") || d.contains("ship") {
return ArcTheme::Project;
}
if d.contains("health") || d.contains("exercise") || d.contains("diet") {
return ArcTheme::Habit;
}
if d.contains("friend") || d.contains("family") || d.contains("partner") {
return ArcTheme::Relationship;
}
}
if episode.sentiment < -0.5 {
ArcTheme::Challenge
} else if episode.sentiment > 0.5 {
ArcTheme::Discovery
} else {
ArcTheme::Project
}
}
fn infer_chapter_type(chapter_num: usize, arc_status: ArcStatus) -> ChapterType {
if chapter_num == 1 {
ChapterType::Setup
} else if arc_status == ArcStatus::Resolved {
ChapterType::Resolution
} else if chapter_num <= 3 {
ChapterType::Rising
} else {
ChapterType::Rising }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::{NodeId, NodeKind};
fn make_episode(
seq: u32,
summary: &str,
participants: Vec<NodeId>,
domains: Vec<&str>,
sentiment: f64,
ts: u64,
) -> NarrativeEpisode {
NarrativeEpisode {
episode_id: NodeId::new(NodeKind::Episode, seq),
summary: summary.to_string(),
participants,
domains: domains.into_iter().map(|d| d.to_string()).collect(),
sentiment,
timestamp_ms: ts,
related_goal: None,
}
}
fn alice() -> NodeId {
NodeId::new(NodeKind::Entity, 100)
}
fn bob() -> NodeId {
NodeId::new(NodeKind::Entity, 101)
}
#[test]
fn test_arc_assignment_creates_new_arc() {
let mut timeline = AutobiographicalTimeline::default();
let ep = make_episode(
1,
"Started learning Rust",
vec![alice()],
vec!["learning"],
0.5,
1000,
);
let arc_id = assign_to_arc(&ep, &mut timeline);
assert_eq!(timeline.arcs.len(), 1);
assert_eq!(timeline.find_arc(arc_id).unwrap().episode_count(), 1);
assert_eq!(
timeline.find_arc(arc_id).unwrap().status,
ArcStatus::Emerging,
);
}
#[test]
fn test_arc_assignment_adds_to_existing() {
let mut timeline = AutobiographicalTimeline::default();
let now = 1_000_000;
let ep1 = make_episode(
1,
"Started learning Rust",
vec![alice()],
vec!["learning"],
0.5,
now,
);
let ep2 = make_episode(
2,
"Read Rust book ch1",
vec![alice()],
vec!["learning"],
0.6,
now + 3600_000,
);
assign_to_arc(&ep1, &mut timeline);
assign_to_arc(&ep2, &mut timeline);
assert_eq!(timeline.arcs.len(), 1, "Should reuse existing arc");
assert_eq!(timeline.arcs[0].episode_count(), 2);
}
#[test]
fn test_arc_promotes_to_active() {
let mut timeline = AutobiographicalTimeline::default();
let now = 1_000_000;
for i in 0..3 {
let ep = make_episode(
i + 1,
&format!("Learning episode {}", i + 1),
vec![alice()],
vec!["learning"],
0.5,
now + i as u64 * 3600_000,
);
assign_to_arc(&ep, &mut timeline);
}
assert_eq!(timeline.arcs[0].status, ArcStatus::Active);
}
#[test]
fn test_chapter_boundary_time_gap() {
let mut timeline = AutobiographicalTimeline::default();
let now = 1_000_000;
let ep1 = make_episode(1, "Day 1", vec![alice()], vec!["project"], 0.5, now);
assign_to_arc(&ep1, &mut timeline);
let ep2 = make_episode(
2,
"Day 4",
vec![alice()],
vec!["project"],
0.5,
now + 3 * 86_400_000,
);
assign_to_arc(&ep2, &mut timeline);
assert_eq!(
timeline.arcs[0].chapters.len(),
2,
"Should create new chapter"
);
}
#[test]
fn test_chapter_boundary_sentiment_reversal() {
let mut timeline = AutobiographicalTimeline::default();
let now = 1_000_000;
let ep1 = make_episode(1, "Great day", vec![alice()], vec!["work"], 0.8, now);
assign_to_arc(&ep1, &mut timeline);
let ep2 = make_episode(
2,
"Terrible day",
vec![alice()],
vec!["work"],
-0.5,
now + 3600_000,
);
assign_to_arc(&ep2, &mut timeline);
assert!(
timeline.arcs[0].chapters.len() >= 2,
"Sentiment reversal should create chapter"
);
}
#[test]
fn test_turning_point_detection() {
let mut timeline = AutobiographicalTimeline::default();
let now = 1_000_000;
for i in 0..3 {
let ep = make_episode(
i + 1,
"Normal day",
vec![alice()],
vec!["work"],
0.3,
now + i as u64 * 3600_000,
);
assign_to_arc(&ep, &mut timeline);
}
let ep4 = make_episode(
4,
"Got promoted!",
vec![alice()],
vec!["work"],
0.9,
now + 4 * 3600_000,
);
assign_to_arc(&ep4, &mut timeline);
assert!(
!timeline.arcs[0].turning_points.is_empty(),
"Should detect turning point on big sentiment shift"
);
}
#[test]
fn test_arc_resolution_positive_sentiment() {
let arc = NarrativeArc {
id: ArcId(1),
title: "test".to_string(),
theme: ArcTheme::Project,
chapters: vec![Chapter {
title: "ch1".to_string(),
episodes: vec![],
summary: String::new(),
chapter_type: ChapterType::Resolution,
time_span: (0, 0),
sentiment_trajectory: vec![0.6, 0.7, 0.8],
}],
participants: vec![],
domains: vec![],
status: ArcStatus::Active,
emotional_valence: 0.7,
started_at: 0,
last_updated_at: 1000,
turning_points: vec![],
};
assert!(detect_arc_resolution(&arc, 2000));
}
#[test]
fn test_arc_resolution_stalled() {
let arc = NarrativeArc {
id: ArcId(1),
title: "test".to_string(),
theme: ArcTheme::Project,
chapters: vec![Chapter {
title: "ch1".to_string(),
episodes: vec![],
summary: String::new(),
chapter_type: ChapterType::Setup,
time_span: (0, 0),
sentiment_trajectory: vec![0.3],
}],
participants: vec![],
domains: vec![],
status: ArcStatus::Active,
emotional_valence: 0.3,
started_at: 0,
last_updated_at: 0,
turning_points: vec![],
};
let now = 31 * 86_400_000; assert!(detect_arc_resolution(&arc, now));
}
#[test]
fn test_timeline_queries() {
let mut timeline = AutobiographicalTimeline::default();
let now = 1_000_000;
let ep1 = make_episode(1, "Learn", vec![alice()], vec!["learning"], 0.5, now);
assign_to_arc(&ep1, &mut timeline);
let ep2 = make_episode(2, "Code", vec![bob()], vec!["project"], 0.3, now);
assign_to_arc(&ep2, &mut timeline);
let r1 = query_timeline(&timeline, &NarrativeQuery::ArcsByParticipant(alice()));
assert_eq!(r1.arcs.len(), 1);
let r2 = query_timeline(
&timeline,
&NarrativeQuery::ArcsByDomain("project".to_string()),
);
assert_eq!(r2.arcs.len(), 1);
let r3 = query_timeline(&timeline, &NarrativeQuery::ActiveArcs);
assert_eq!(r3.arcs.len(), 2); }
#[test]
fn test_arc_merge() {
let now = 1_000_000;
let a = NarrativeArc {
id: ArcId(1),
title: "Arc A".to_string(),
theme: ArcTheme::Project,
chapters: vec![Chapter {
title: "ch1".to_string(),
episodes: vec![NodeId::new(NodeKind::Episode, 1)],
summary: "start".to_string(),
chapter_type: ChapterType::Setup,
time_span: (now, now + 1000),
sentiment_trajectory: vec![0.5],
}],
participants: vec![alice()],
domains: vec!["work".to_string()],
status: ArcStatus::Active,
emotional_valence: 0.5,
started_at: now,
last_updated_at: now + 1000,
turning_points: vec![],
};
let b = NarrativeArc {
id: ArcId(2),
title: "Arc B".to_string(),
theme: ArcTheme::Project,
chapters: vec![Chapter {
title: "ch1".to_string(),
episodes: vec![NodeId::new(NodeKind::Episode, 2)],
summary: "continue".to_string(),
chapter_type: ChapterType::Rising,
time_span: (now + 2000, now + 3000),
sentiment_trajectory: vec![0.6],
}],
participants: vec![alice(), bob()],
domains: vec!["work".to_string(), "coding".to_string()],
status: ArcStatus::Active,
emotional_valence: 0.6,
started_at: now + 2000,
last_updated_at: now + 3000,
turning_points: vec![],
};
let merged = merge_arcs(&a, &b);
assert_eq!(merged.episode_count(), 2);
assert_eq!(merged.participants.len(), 2); assert_eq!(merged.domains.len(), 2); assert_eq!(merged.id, ArcId(1)); }
#[test]
fn test_arc_health_check_stalled() {
let now = 100 * 86_400_000u64; let old_update = 80 * 86_400_000u64;
let timeline = AutobiographicalTimeline {
arcs: vec![NarrativeArc {
id: ArcId(1),
title: "Stalled arc".to_string(),
theme: ArcTheme::Project,
chapters: vec![],
participants: vec![],
domains: vec![],
status: ArcStatus::Active,
emotional_valence: 0.0,
started_at: 0,
last_updated_at: old_update,
turning_points: vec![],
}],
milestones: vec![],
next_arc_id: 2,
};
let alerts = arc_health_check(&timeline, now);
assert!(!alerts.is_empty());
assert_eq!(alerts[0].alert_type, ArcAlertType::Stalled);
}
#[test]
fn test_arc_health_check_trending_negative() {
let now = 1_000_000;
let timeline = AutobiographicalTimeline {
arcs: vec![NarrativeArc {
id: ArcId(1),
title: "Sad arc".to_string(),
theme: ArcTheme::Challenge,
chapters: vec![Chapter {
title: "ch1".to_string(),
episodes: vec![],
summary: String::new(),
chapter_type: ChapterType::Falling,
time_span: (0, now),
sentiment_trajectory: vec![-0.3, -0.5, -0.7],
}],
participants: vec![],
domains: vec![],
status: ArcStatus::Active,
emotional_valence: -0.5,
started_at: 0,
last_updated_at: now,
turning_points: vec![],
}],
milestones: vec![],
next_arc_id: 2,
};
let alerts = arc_health_check(&timeline, now);
let negative = alerts
.iter()
.find(|a| a.alert_type == ArcAlertType::TrendingNegative);
assert!(negative.is_some(), "Should detect negative trend");
}
#[test]
fn test_generate_arc_summary() {
let arc = NarrativeArc {
id: ArcId(1),
title: "Learning Rust".to_string(),
theme: ArcTheme::Growth,
chapters: vec![Chapter {
title: "ch1".to_string(),
episodes: vec![
NodeId::new(NodeKind::Episode, 1),
NodeId::new(NodeKind::Episode, 2),
],
summary: String::new(),
chapter_type: ChapterType::Setup,
time_span: (0, 86_400_000),
sentiment_trajectory: vec![0.5, 0.6],
}],
participants: vec![alice()],
domains: vec!["rust".to_string()],
status: ArcStatus::Active,
emotional_valence: 0.55,
started_at: 0,
last_updated_at: 86_400_000,
turning_points: vec![],
};
let summary = generate_arc_summary(&arc);
assert!(summary.contains("Learning Rust"));
assert!(summary.contains("growth"));
assert!(summary.contains("2 episodes"));
assert!(summary.contains("positive"));
}
#[test]
fn test_theme_inference() {
let ep = make_episode(1, "Studied math", vec![], vec!["learning"], 0.5, 0);
assert_eq!(infer_theme_from_episode(&ep), ArcTheme::Growth);
let ep2 = make_episode(2, "Family dinner", vec![], vec!["family"], 0.7, 0);
assert_eq!(infer_theme_from_episode(&ep2), ArcTheme::Relationship);
}
#[test]
fn test_different_participants_create_separate_arcs() {
let mut timeline = AutobiographicalTimeline::default();
let now = 1_000_000;
let ep1 = make_episode(
1,
"Meeting with Alice",
vec![alice()],
vec!["work"],
0.5,
now,
);
let ep2 = make_episode(
2,
"Meeting with Bob",
vec![bob()],
vec!["social"],
0.5,
now + 1000,
);
assign_to_arc(&ep1, &mut timeline);
assign_to_arc(&ep2, &mut timeline);
assert_eq!(timeline.arcs.len(), 2);
}
#[test]
fn test_arc_duration() {
let arc = NarrativeArc {
id: ArcId(1),
title: "test".to_string(),
theme: ArcTheme::Project,
chapters: vec![
Chapter {
title: "ch1".to_string(),
episodes: vec![],
summary: String::new(),
chapter_type: ChapterType::Setup,
time_span: (1000, 2000),
sentiment_trajectory: vec![],
},
Chapter {
title: "ch2".to_string(),
episodes: vec![],
summary: String::new(),
chapter_type: ChapterType::Rising,
time_span: (5000, 8000),
sentiment_trajectory: vec![],
},
],
participants: vec![],
domains: vec![],
status: ArcStatus::Active,
emotional_valence: 0.0,
started_at: 1000,
last_updated_at: 8000,
turning_points: vec![],
};
assert_eq!(arc.duration_ms(), 7000);
}
}