Skip to main content

anno_eval/eval/
shell_nouns.rs

1//! Shell Noun Resolution.
2//!
3//! # Overview
4//!
5//! Shell nouns are abstract nouns (e.g., "fact," "issue," "problem," "possibility")
6//! that require antecedent content to be interpreted. Unlike typical anaphora that
7//! refers to entities, shell nouns refer to propositions, events, or discourse segments.
8//!
9//! # Example
10//!
11//! ```text
12//! "The merger was blocked. This fact surprised analysts."
13//!                          ^^^^^^^^^ shell noun
14//!                          antecedent: "The merger was blocked" (proposition)
15//! ```
16//!
17//! # Shell Noun Categories (Schmid's Taxonomy)
18//!
19//! | Category | Examples | Semantic Type |
20//! |----------|----------|---------------|
21//! | **Factual** | fact, truth, point | Propositions |
22//! | **Linguistic** | statement, claim, argument | Speech acts |
23//! | **Mental** | idea, thought, belief | Cognitive states |
24//! | **Modal** | possibility, chance, risk | Modality |
25//! | **Eventive** | event, situation, process | Events |
26//! | **Circumstantial** | problem, issue, difficulty | States of affairs |
27//!
28//! # Theoretical Connection
29//!
30//! In the higher-order unification view of anaphora (Dalrymple et al. 1991),
31//! shell nouns act as **type constraints** on the property P being recovered.
32//! When we resolve "this problem" to an antecedent, the category of "problem"
33//! (Circumstantial → states of affairs) constrains which discourse segments
34//! are valid solutions.
35//!
36//! This is analogous to typed higher-order unification: the shell noun's
37//! semantic category specifies the type of the variable we're solving for.
38//! A "factual" shell noun (like "fact") requires a propositional antecedent;
39//! an "eventive" shell noun (like "event") requires an event antecedent.
40//!
41//! # Corpora
42//!
43//! - **CSN**: Cataphoric shell nouns (pattern "this [shell noun]")
44//! - **ASN**: 670 English shell nouns with crowdsourced antecedent annotations
45//!
46//! # References
47//!
48//! - Schmid (2000): "English Abstract Nouns as Conceptual Shells"
49//! - Kolhatkar & Hirst (2014): "Resolving Shell Nouns"
50//! - Dalrymple, Shieber & Pereira (1991): "Ellipsis and Higher-Order Unification"
51
52use anno::offset::TextSpan;
53use serde::{Deserialize, Serialize};
54
55/// Category of shell noun (Schmid's taxonomy).
56#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
57pub enum ShellNounCategory {
58    /// Factual: fact, truth, point
59    #[default]
60    Factual,
61    /// Linguistic: statement, claim, argument, news, report
62    Linguistic,
63    /// Mental: idea, thought, belief, notion
64    Mental,
65    /// Modal: possibility, chance, risk, need
66    Modal,
67    /// Eventive: event, situation, process, action
68    Eventive,
69    /// Circumstantial: problem, issue, difficulty, question
70    Circumstantial,
71    /// Unknown or unclassified
72    Other(String),
73}
74
75impl ShellNounCategory {
76    /// Create from string label.
77    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    /// Get canonical label.
90    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
103/// Common shell nouns with their categories.
104pub fn shell_noun_lexicon() -> &'static [(&'static str, ShellNounCategory)] {
105    &[
106        // Factual
107        ("fact", ShellNounCategory::Factual),
108        ("truth", ShellNounCategory::Factual),
109        ("point", ShellNounCategory::Factual),
110        ("matter", ShellNounCategory::Factual),
111        ("reality", ShellNounCategory::Factual),
112        // Linguistic
113        ("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        // Mental
124        ("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        // Modal
135        ("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        // Eventive
143        ("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        // Circumstantial
152        ("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/// A shell noun instance with its antecedent.
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct ShellNounInstance {
166    /// The shell noun phrase (e.g., "this fact")
167    pub shell_phrase: String,
168    /// The shell noun itself (e.g., "fact")
169    pub shell_noun: String,
170    /// Shell noun category
171    pub category: ShellNounCategory,
172    /// Start offset of shell phrase
173    pub shell_start: usize,
174    /// End offset of shell phrase
175    pub shell_end: usize,
176    /// The antecedent content (proposition/event/discourse segment)
177    pub antecedent: Option<ShellNounAntecedent>,
178    /// Whether this is cataphoric (shell noun before antecedent)
179    pub is_cataphoric: bool,
180}
181
182impl ShellNounInstance {
183    /// Create a new shell noun instance.
184    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    /// Set the antecedent.
203    pub fn with_antecedent(mut self, antecedent: ShellNounAntecedent) -> Self {
204        self.antecedent = Some(antecedent);
205        self
206    }
207
208    /// Mark as cataphoric.
209    pub fn as_cataphoric(mut self) -> Self {
210        self.is_cataphoric = true;
211        self
212    }
213
214    /// Check if resolved (has antecedent).
215    pub fn is_resolved(&self) -> bool {
216        self.antecedent.is_some()
217    }
218}
219
220/// Antecedent for a shell noun (typically a clause or proposition).
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct ShellNounAntecedent {
223    /// The antecedent text
224    pub text: String,
225    /// Start offset in document
226    pub start: usize,
227    /// End offset in document
228    pub end: usize,
229    /// Type of antecedent
230    pub antecedent_type: AntecedentType,
231    /// Sentence index containing antecedent
232    pub sentence_idx: Option<usize>,
233}
234
235impl ShellNounAntecedent {
236    /// Create a new antecedent.
237    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    /// Set the antecedent type.
248    pub fn with_type(mut self, ant_type: AntecedentType) -> Self {
249        self.antecedent_type = ant_type;
250        self
251    }
252}
253
254/// Type of shell noun antecedent.
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
256pub enum AntecedentType {
257    /// Full clause/sentence
258    #[default]
259    Clause,
260    /// Verb phrase
261    VerbPhrase,
262    /// Noun phrase (rare for shell nouns)
263    NounPhrase,
264    /// Multiple sentences/discourse segment
265    DiscourseSegment,
266    /// Implicit (must be inferred)
267    Implicit,
268}
269
270/// Shell noun resolution for a document.
271#[derive(Debug, Clone, Default, Serialize, Deserialize)]
272pub struct ShellNounDocument {
273    /// Document ID
274    pub id: String,
275    /// Document text
276    pub text: String,
277    /// Detected shell noun instances
278    pub instances: Vec<ShellNounInstance>,
279}
280
281impl ShellNounDocument {
282    /// Create a new document.
283    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    /// Add a shell noun instance.
292    pub fn add_instance(&mut self, instance: ShellNounInstance) {
293        self.instances.push(instance);
294    }
295
296    /// Number of instances.
297    pub fn len(&self) -> usize {
298        self.instances.len()
299    }
300
301    /// Check if empty.
302    pub fn is_empty(&self) -> bool {
303        self.instances.is_empty()
304    }
305
306    /// Get resolved instances only.
307    pub fn resolved(&self) -> Vec<&ShellNounInstance> {
308        self.instances.iter().filter(|i| i.is_resolved()).collect()
309    }
310
311    /// Get unresolved instances.
312    pub fn unresolved(&self) -> Vec<&ShellNounInstance> {
313        self.instances.iter().filter(|i| !i.is_resolved()).collect()
314    }
315}
316
317/// Simple pattern-based shell noun detector.
318pub struct ShellNounDetector {
319    /// Shell noun lexicon
320    lexicon: std::collections::HashSet<String>,
321}
322
323impl Default for ShellNounDetector {
324    fn default() -> Self {
325        Self::new()
326    }
327}
328
329impl ShellNounDetector {
330    /// Create a new detector with default lexicon.
331    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    /// Detect shell nouns in text.
340    ///
341    /// Looks for patterns like "this [shell noun]", "the [shell noun] that", etc.
342    pub fn detect(&self, text: &str) -> Vec<ShellNounInstance> {
343        let mut instances = Vec::new();
344        // Use ASCII-only lowercasing so match indices stay aligned with the original text.
345        // Unicode `to_lowercase()` can change string length (e.g., ß → ss), invalidating indices.
346        // Shell noun patterns are English/ASCII, so this is the correct behavior here.
347        let lower = text.to_ascii_lowercase();
348
349        // Pattern: "this/that/the [shell noun]"
350        for noun in &self.lexicon {
351            // "this [noun]" pattern (cataphoric)
352            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            // "that [noun]" pattern
364            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            // "the [noun] that" pattern (cataphoric)
378            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        // Sort by position
391        instances.sort_by_key(|i| i.shell_start);
392        instances
393    }
394
395    /// Check if a word is a shell noun.
396    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        // Regression: Unicode before the pattern must not break match indexing, and offsets
452        // must be character offsets (not bytes).
453        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}