1use std::collections::HashMap;
3
4#[derive(Debug, Clone)]
6pub struct NarrativeContext {
7 passages: Vec<String>,
9 window_size: usize,
11 recent_openings: Vec<String>,
13 word_counts: HashMap<String, usize>,
15 pub entity_mentions: HashMap<String, usize>,
17}
18
19impl Default for NarrativeContext {
20 fn default() -> Self {
21 Self::new(10)
22 }
23}
24
25#[derive(Debug, Clone, PartialEq)]
27pub enum RepetitionIssue {
28 RepeatedOpening(String),
30 OverusedWord { word: String, count: usize },
32 StructuralMonotony,
34}
35
36const 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 pub fn record(&mut self, text: &str) {
59 self.passages.push(text.to_string());
61 if self.passages.len() > self.window_size {
62 self.passages.remove(0);
63 }
64
65 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 self.rebuild_word_counts();
76 }
77
78 pub fn check_repetition(&self, candidate: &str) -> Vec<RepetitionIssue> {
80 let mut issues = Vec::new();
81
82 let opening = extract_opening(candidate);
84 if !opening.is_empty() && self.recent_openings.contains(&opening) {
85 issues.push(RepetitionIssue::RepeatedOpening(opening));
86 }
87
88 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 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 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
136fn 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
145fn 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
156fn 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 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 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())); }
262}