Skip to main content

narrative_engine/core/
context.rs

1/// Narrative context — anti-repetition tracking and pronoun management.
2use std::collections::HashMap;
3
4/// A sliding window of recently generated passages for repetition detection.
5#[derive(Debug, Clone)]
6pub struct NarrativeContext {
7    /// Recent passages (most recent last).
8    passages: Vec<String>,
9    /// Maximum number of passages to track.
10    window_size: usize,
11    /// Recent sentence openings (first 3 words, lowercased).
12    recent_openings: Vec<String>,
13    /// Word frequency counts across the window.
14    word_counts: HashMap<String, usize>,
15    /// Entity mention counts for pronoun decisions.
16    pub entity_mentions: HashMap<String, usize>,
17}
18
19impl Default for NarrativeContext {
20    fn default() -> Self {
21        Self::new(10)
22    }
23}
24
25/// An issue detected by repetition checking.
26#[derive(Debug, Clone, PartialEq)]
27pub enum RepetitionIssue {
28    /// The candidate's opening words match a recent passage.
29    RepeatedOpening(String),
30    /// A significant word appears too many times across recent context.
31    OverusedWord { word: String, count: usize },
32    /// Sentence lengths are too uniform across recent context.
33    StructuralMonotony,
34}
35
36/// Stopwords that don't count as "significant" for repetition tracking.
37const STOPWORDS: &[&str] = &[
38    "the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by",
39    "from", "is", "it", "as", "was", "are", "be", "been", "had", "has", "have", "that", "this",
40    "not", "her", "his", "she", "he", "they", "them", "their", "its", "into", "than", "then",
41    "were", "will", "would", "could", "should", "did", "does", "do", "all", "each", "every",
42    "both", "few", "more", "most", "other", "some", "such", "only", "own", "same", "so", "just",
43    "very",
44];
45
46impl NarrativeContext {
47    pub fn new(window_size: usize) -> Self {
48        Self {
49            passages: Vec::new(),
50            window_size,
51            recent_openings: Vec::new(),
52            word_counts: HashMap::new(),
53            entity_mentions: HashMap::new(),
54        }
55    }
56
57    /// Record a generated passage into the sliding window.
58    pub fn record(&mut self, text: &str) {
59        // Add to passages
60        self.passages.push(text.to_string());
61        if self.passages.len() > self.window_size {
62            self.passages.remove(0);
63        }
64
65        // Track opening words
66        let opening = extract_opening(text);
67        if !opening.is_empty() {
68            self.recent_openings.push(opening);
69            if self.recent_openings.len() > self.window_size {
70                self.recent_openings.remove(0);
71            }
72        }
73
74        // Rebuild word counts from current window
75        self.rebuild_word_counts();
76    }
77
78    /// Check a candidate passage for repetition issues.
79    pub fn check_repetition(&self, candidate: &str) -> Vec<RepetitionIssue> {
80        let mut issues = Vec::new();
81
82        // Check repeated openings
83        let opening = extract_opening(candidate);
84        if !opening.is_empty() && self.recent_openings.contains(&opening) {
85            issues.push(RepetitionIssue::RepeatedOpening(opening));
86        }
87
88        // Check overused words (combining existing counts with candidate)
89        let candidate_words = extract_significant_words(candidate);
90        for word in &candidate_words {
91            let existing = self.word_counts.get(word.as_str()).copied().unwrap_or(0);
92            let total = existing + 1;
93            if total >= 4 {
94                issues.push(RepetitionIssue::OverusedWord {
95                    word: word.clone(),
96                    count: total,
97                });
98            }
99        }
100
101        // Check structural monotony
102        if self.passages.len() >= 3 {
103            let mut lengths: Vec<f64> = self
104                .passages
105                .iter()
106                .flat_map(|p| sentence_lengths(p))
107                .collect();
108            lengths.extend(sentence_lengths(candidate));
109
110            if lengths.len() >= 4 {
111                let mean: f64 = lengths.iter().sum::<f64>() / lengths.len() as f64;
112                let variance: f64 =
113                    lengths.iter().map(|l| (l - mean).powi(2)).sum::<f64>() / lengths.len() as f64;
114                let stddev = variance.sqrt();
115
116                // If standard deviation is very low, sentences are monotonously uniform
117                if stddev < 2.0 && mean > 3.0 {
118                    issues.push(RepetitionIssue::StructuralMonotony);
119                }
120            }
121        }
122
123        issues
124    }
125
126    fn rebuild_word_counts(&mut self) {
127        self.word_counts.clear();
128        for passage in &self.passages {
129            for word in extract_significant_words(passage) {
130                *self.word_counts.entry(word).or_default() += 1;
131            }
132        }
133    }
134}
135
136/// Extract the first 3 words of text, lowercased, as the "opening".
137fn extract_opening(text: &str) -> String {
138    text.split_whitespace()
139        .take(3)
140        .map(|w| w.to_lowercase())
141        .collect::<Vec<_>>()
142        .join(" ")
143}
144
145/// Extract "significant" words: length > 4, not a stopword.
146fn extract_significant_words(text: &str) -> Vec<String> {
147    text.split_whitespace()
148        .map(|w| {
149            w.trim_matches(|c: char| !c.is_alphanumeric())
150                .to_lowercase()
151        })
152        .filter(|w| w.len() > 4 && !STOPWORDS.contains(&w.as_str()))
153        .collect()
154}
155
156/// Get sentence lengths (word count per sentence) from text.
157fn sentence_lengths(text: &str) -> Vec<f64> {
158    text.split(['.', '!', '?'])
159        .map(|s| s.split_whitespace().count() as f64)
160        .filter(|&len| len > 0.0)
161        .collect()
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn context_default() {
170        let ctx = NarrativeContext::default();
171        assert!(ctx.passages.is_empty());
172    }
173
174    #[test]
175    fn record_and_window() {
176        let mut ctx = NarrativeContext::new(3);
177        ctx.record("First passage.");
178        ctx.record("Second passage.");
179        ctx.record("Third passage.");
180        ctx.record("Fourth passage.");
181        // Window should only keep last 3
182        assert_eq!(ctx.passages.len(), 3);
183        assert_eq!(ctx.passages[0], "Second passage.");
184    }
185
186    #[test]
187    fn repeated_opening_detected() {
188        let mut ctx = NarrativeContext::default();
189        ctx.record("The evening was quiet and still.");
190        let issues = ctx.check_repetition("The evening was loud and chaotic.");
191        assert!(issues
192            .iter()
193            .any(|i| matches!(i, RepetitionIssue::RepeatedOpening(_))));
194    }
195
196    #[test]
197    fn no_repeated_opening_for_different_starts() {
198        let mut ctx = NarrativeContext::default();
199        ctx.record("The evening was quiet.");
200        let issues = ctx.check_repetition("A silence settled over the room.");
201        assert!(!issues
202            .iter()
203            .any(|i| matches!(i, RepetitionIssue::RepeatedOpening(_))));
204    }
205
206    #[test]
207    fn overused_word_detected() {
208        let mut ctx = NarrativeContext::default();
209        ctx.record("The silence was deafening in the silence.");
210        ctx.record("A terrible silence filled the room.");
211        ctx.record("There was nothing but silence.");
212        let issues = ctx.check_repetition("The silence continued.");
213        assert!(issues.iter().any(|i| matches!(
214            i,
215            RepetitionIssue::OverusedWord { word, .. } if word == "silence"
216        )));
217    }
218
219    #[test]
220    fn structural_monotony_detected() {
221        let mut ctx = NarrativeContext::default();
222        // All sentences of very similar length (5 words)
223        ctx.record("She looked at the door.");
224        ctx.record("He turned to the wall.");
225        ctx.record("They walked to the car.");
226        let issues = ctx.check_repetition("She moved to the room.");
227        assert!(issues
228            .iter()
229            .any(|i| matches!(i, RepetitionIssue::StructuralMonotony)));
230    }
231
232    #[test]
233    fn no_monotony_with_varied_lengths() {
234        let mut ctx = NarrativeContext::default();
235        ctx.record("She looked at the door with a growing sense of unease.");
236        ctx.record("He turned.");
237        ctx.record("They walked to the car and drove away into the night, headlights cutting through the fog.");
238        let issues = ctx.check_repetition("Nothing happened.");
239        assert!(!issues
240            .iter()
241            .any(|i| matches!(i, RepetitionIssue::StructuralMonotony)));
242    }
243
244    #[test]
245    fn extract_opening_works() {
246        assert_eq!(extract_opening("The evening was quiet."), "the evening was");
247        assert_eq!(extract_opening("Hello."), "hello.");
248        assert_eq!(extract_opening(""), "");
249    }
250
251    #[test]
252    fn significant_words_filter() {
253        let words = extract_significant_words("The quick brown silence filled the empty room.");
254        assert!(words.contains(&"quick".to_string()));
255        assert!(words.contains(&"brown".to_string()));
256        assert!(words.contains(&"silence".to_string()));
257        assert!(words.contains(&"filled".to_string()));
258        assert!(words.contains(&"empty".to_string()));
259        assert!(!words.contains(&"the".to_string()));
260        assert!(!words.contains(&"room".to_string())); // only 4 chars
261    }
262}