1use anno::offset::TextSpan;
53use serde::{Deserialize, Serialize};
54
55#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
57pub enum ShellNounCategory {
58 #[default]
60 Factual,
61 Linguistic,
63 Mental,
65 Modal,
67 Eventive,
69 Circumstantial,
71 Other(String),
73}
74
75impl ShellNounCategory {
76 pub fn from_label(label: &str) -> Self {
78 match label.to_lowercase().as_str() {
79 "factual" | "fact" => Self::Factual,
80 "linguistic" | "speech" => Self::Linguistic,
81 "mental" | "cognitive" => Self::Mental,
82 "modal" | "modality" => Self::Modal,
83 "eventive" | "event" => Self::Eventive,
84 "circumstantial" | "circumstance" => Self::Circumstantial,
85 other => Self::Other(other.to_string()),
86 }
87 }
88
89 pub fn as_label(&self) -> &str {
91 match self {
92 Self::Factual => "factual",
93 Self::Linguistic => "linguistic",
94 Self::Mental => "mental",
95 Self::Modal => "modal",
96 Self::Eventive => "eventive",
97 Self::Circumstantial => "circumstantial",
98 Self::Other(s) => s.as_str(),
99 }
100 }
101}
102
103pub fn shell_noun_lexicon() -> &'static [(&'static str, ShellNounCategory)] {
105 &[
106 ("fact", ShellNounCategory::Factual),
108 ("truth", ShellNounCategory::Factual),
109 ("point", ShellNounCategory::Factual),
110 ("matter", ShellNounCategory::Factual),
111 ("reality", ShellNounCategory::Factual),
112 ("statement", ShellNounCategory::Linguistic),
114 ("claim", ShellNounCategory::Linguistic),
115 ("argument", ShellNounCategory::Linguistic),
116 ("news", ShellNounCategory::Linguistic),
117 ("report", ShellNounCategory::Linguistic),
118 ("announcement", ShellNounCategory::Linguistic),
119 ("message", ShellNounCategory::Linguistic),
120 ("story", ShellNounCategory::Linguistic),
121 ("explanation", ShellNounCategory::Linguistic),
122 ("conclusion", ShellNounCategory::Linguistic),
123 ("idea", ShellNounCategory::Mental),
125 ("thought", ShellNounCategory::Mental),
126 ("belief", ShellNounCategory::Mental),
127 ("notion", ShellNounCategory::Mental),
128 ("view", ShellNounCategory::Mental),
129 ("opinion", ShellNounCategory::Mental),
130 ("impression", ShellNounCategory::Mental),
131 ("feeling", ShellNounCategory::Mental),
132 ("assumption", ShellNounCategory::Mental),
133 ("hypothesis", ShellNounCategory::Mental),
134 ("possibility", ShellNounCategory::Modal),
136 ("chance", ShellNounCategory::Modal),
137 ("risk", ShellNounCategory::Modal),
138 ("need", ShellNounCategory::Modal),
139 ("requirement", ShellNounCategory::Modal),
140 ("ability", ShellNounCategory::Modal),
141 ("tendency", ShellNounCategory::Modal),
142 ("event", ShellNounCategory::Eventive),
144 ("situation", ShellNounCategory::Eventive),
145 ("process", ShellNounCategory::Eventive),
146 ("action", ShellNounCategory::Eventive),
147 ("activity", ShellNounCategory::Eventive),
148 ("development", ShellNounCategory::Eventive),
149 ("change", ShellNounCategory::Eventive),
150 ("movement", ShellNounCategory::Eventive),
151 ("problem", ShellNounCategory::Circumstantial),
153 ("issue", ShellNounCategory::Circumstantial),
154 ("difficulty", ShellNounCategory::Circumstantial),
155 ("question", ShellNounCategory::Circumstantial),
156 ("challenge", ShellNounCategory::Circumstantial),
157 ("crisis", ShellNounCategory::Circumstantial),
158 ("phenomenon", ShellNounCategory::Circumstantial),
159 ("aspect", ShellNounCategory::Circumstantial),
160 ]
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct ShellNounInstance {
166 pub shell_phrase: String,
168 pub shell_noun: String,
170 pub category: ShellNounCategory,
172 pub shell_start: usize,
174 pub shell_end: usize,
176 pub antecedent: Option<ShellNounAntecedent>,
178 pub is_cataphoric: bool,
180}
181
182impl ShellNounInstance {
183 pub fn new(shell_phrase: &str, shell_noun: &str, start: usize, end: usize) -> Self {
185 let category = shell_noun_lexicon()
186 .iter()
187 .find(|(noun, _)| *noun == shell_noun.to_lowercase())
188 .map(|(_, cat)| cat.clone())
189 .unwrap_or_else(|| ShellNounCategory::Other(shell_noun.to_string()));
190
191 Self {
192 shell_phrase: shell_phrase.to_string(),
193 shell_noun: shell_noun.to_string(),
194 category,
195 shell_start: start,
196 shell_end: end,
197 antecedent: None,
198 is_cataphoric: false,
199 }
200 }
201
202 pub fn with_antecedent(mut self, antecedent: ShellNounAntecedent) -> Self {
204 self.antecedent = Some(antecedent);
205 self
206 }
207
208 pub fn as_cataphoric(mut self) -> Self {
210 self.is_cataphoric = true;
211 self
212 }
213
214 pub fn is_resolved(&self) -> bool {
216 self.antecedent.is_some()
217 }
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct ShellNounAntecedent {
223 pub text: String,
225 pub start: usize,
227 pub end: usize,
229 pub antecedent_type: AntecedentType,
231 pub sentence_idx: Option<usize>,
233}
234
235impl ShellNounAntecedent {
236 pub fn new(text: &str, start: usize, end: usize) -> Self {
238 Self {
239 text: text.to_string(),
240 start,
241 end,
242 antecedent_type: AntecedentType::Clause,
243 sentence_idx: None,
244 }
245 }
246
247 pub fn with_type(mut self, ant_type: AntecedentType) -> Self {
249 self.antecedent_type = ant_type;
250 self
251 }
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
256pub enum AntecedentType {
257 #[default]
259 Clause,
260 VerbPhrase,
262 NounPhrase,
264 DiscourseSegment,
266 Implicit,
268}
269
270#[derive(Debug, Clone, Default, Serialize, Deserialize)]
272pub struct ShellNounDocument {
273 pub id: String,
275 pub text: String,
277 pub instances: Vec<ShellNounInstance>,
279}
280
281impl ShellNounDocument {
282 pub fn new(id: &str, text: &str) -> Self {
284 Self {
285 id: id.to_string(),
286 text: text.to_string(),
287 instances: Vec::new(),
288 }
289 }
290
291 pub fn add_instance(&mut self, instance: ShellNounInstance) {
293 self.instances.push(instance);
294 }
295
296 pub fn len(&self) -> usize {
298 self.instances.len()
299 }
300
301 pub fn is_empty(&self) -> bool {
303 self.instances.is_empty()
304 }
305
306 pub fn resolved(&self) -> Vec<&ShellNounInstance> {
308 self.instances.iter().filter(|i| i.is_resolved()).collect()
309 }
310
311 pub fn unresolved(&self) -> Vec<&ShellNounInstance> {
313 self.instances.iter().filter(|i| !i.is_resolved()).collect()
314 }
315}
316
317pub struct ShellNounDetector {
319 lexicon: std::collections::HashSet<String>,
321}
322
323impl Default for ShellNounDetector {
324 fn default() -> Self {
325 Self::new()
326 }
327}
328
329impl ShellNounDetector {
330 pub fn new() -> Self {
332 let lexicon = shell_noun_lexicon()
333 .iter()
334 .map(|(noun, _)| noun.to_string())
335 .collect();
336 Self { lexicon }
337 }
338
339 pub fn detect(&self, text: &str) -> Vec<ShellNounInstance> {
343 let mut instances = Vec::new();
344 let lower = text.to_ascii_lowercase();
348
349 for noun in &self.lexicon {
351 let pattern = format!("this {}", noun);
353 for (idx, _) in lower.match_indices(&pattern) {
354 let end = idx + pattern.len();
355 let span = TextSpan::from_bytes(text, idx, end);
356 let shell_phrase = span.extract(text);
357 let mut instance =
358 ShellNounInstance::new(shell_phrase, noun, span.char_start, span.char_end);
359 instance.is_cataphoric = true;
360 instances.push(instance);
361 }
362
363 let pattern = format!("that {}", noun);
365 for (idx, _) in lower.match_indices(&pattern) {
366 let end = idx + pattern.len();
367 let span = TextSpan::from_bytes(text, idx, end);
368 let shell_phrase = span.extract(text);
369 instances.push(ShellNounInstance::new(
370 shell_phrase,
371 noun,
372 span.char_start,
373 span.char_end,
374 ));
375 }
376
377 let pattern = format!("the {} that", noun);
379 for (idx, _) in lower.match_indices(&pattern) {
380 let end = idx + format!("the {}", noun).len();
381 let span = TextSpan::from_bytes(text, idx, end);
382 let shell_phrase = span.extract(text);
383 let mut instance =
384 ShellNounInstance::new(shell_phrase, noun, span.char_start, span.char_end);
385 instance.is_cataphoric = true;
386 instances.push(instance);
387 }
388 }
389
390 instances.sort_by_key(|i| i.shell_start);
392 instances
393 }
394
395 pub fn is_shell_noun(&self, word: &str) -> bool {
397 self.lexicon.contains(&word.to_lowercase())
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use anno::offset::TextSpan;
405
406 #[test]
407 fn test_shell_noun_category() {
408 assert_eq!(
409 ShellNounCategory::from_label("factual"),
410 ShellNounCategory::Factual
411 );
412 assert_eq!(
413 ShellNounCategory::from_label("modal"),
414 ShellNounCategory::Modal
415 );
416 }
417
418 #[test]
419 fn test_shell_noun_instance() {
420 let instance = ShellNounInstance::new("this fact", "fact", 0, 9);
421 assert_eq!(instance.category, ShellNounCategory::Factual);
422 assert!(!instance.is_resolved());
423 }
424
425 #[test]
426 fn test_shell_noun_detector() {
427 let detector = ShellNounDetector::new();
428
429 let text = "The merger was blocked. This fact surprised analysts.";
430 let instances = detector.detect(text);
431
432 assert_eq!(instances.len(), 1);
433 assert_eq!(instances[0].shell_noun, "fact");
434 assert!(instances[0].is_cataphoric);
435 }
436
437 #[test]
438 fn test_detector_multiple_patterns() {
439 let detector = ShellNounDetector::new();
440
441 let text = "This problem is serious. The issue that concernos us is timing.";
442 let instances = detector.detect(text);
443
444 assert_eq!(instances.len(), 2);
445 assert_eq!(instances[0].shell_noun, "problem");
446 assert_eq!(instances[1].shell_noun, "issue");
447 }
448
449 #[test]
450 fn test_shell_noun_detector_unicode_safe_indices() {
451 let detector = ShellNounDetector::new();
454 let text = "Müller said: this fact matters.";
455 let instances = detector.detect(text);
456 let inst = instances
457 .iter()
458 .find(|i| i.shell_phrase == "this fact")
459 .expect("expected to detect 'this fact'");
460 let round_trip = TextSpan::from_chars(text, inst.shell_start, inst.shell_end).extract(text);
461 assert_eq!(round_trip, inst.shell_phrase);
462 }
463
464 #[test]
465 fn test_shell_noun_document() {
466 let mut doc = ShellNounDocument::new("doc1", "This fact is important.");
467
468 let antecedent = ShellNounAntecedent::new("The merger was blocked", 0, 22);
469 let instance =
470 ShellNounInstance::new("This fact", "fact", 0, 9).with_antecedent(antecedent);
471
472 doc.add_instance(instance);
473
474 assert_eq!(doc.len(), 1);
475 assert_eq!(doc.resolved().len(), 1);
476 assert_eq!(doc.unresolved().len(), 0);
477 }
478}