Skip to main content

anno_eval/eval/
incremental_coref.rs

1//! Incremental Coreference Resolution for Book-Scale Documents
2//!
3//! This module implements an incremental coreference resolver inspired by
4//! Longdoc (Toshniwal et al., 2020, 2021), designed for processing documents
5//! that exceed the context window of standard transformer models.
6//!
7//! # Motivation: The Book-Scale Challenge
8//!
9//! Standard coreference systems struggle at book scale (200k+ tokens) because:
10//!
11//! 1. **Memory limitations**: naive “full-context” encoding does not scale.
12//! 2. **Long-range dependencies**: coreferent mentions can be very far apart.
13//! 3. **Metric divergence**: aggregate metrics can hide long-document failure modes.
14//!
15//! # The Incremental Approach
16//!
17//! Instead of processing the entire document at once, incremental coref:
18//!
19//! 1. Processes text in overlapping **windows** (typically 1500-4000 tokens)
20//! 2. Maintains a **memory** of active entity clusters
21//! 3. Links new mentions to existing clusters or creates new ones
22//! 4. Optionally "forgets" rarely-used clusters to bound memory
23//!
24//! ```text
25//! Document: [====Window 1====][====Window 2====][====Window 3====]...
26//!                   |                 |                 |
27//!                   v                 v                 v
28//!           [Mention Detection] [Link to Memory] [Update Memory]
29//!                   |                 |                 |
30//!                   +--------+--------+--------+--------+
31//!                            |
32//!                    [Entity Memory]
33//!                     Cluster 1: [John, he, Smith, ...]
34//!                     Cluster 2: [Mary, she, ...]
35//!                     ...
36//! ```
37//!
38//! # Memory Policies
39//!
40//! Two policies from Longdoc/Dual-cache:
41//!
42//! - **Unbounded**: Keep all clusters forever (accurate but memory-intensive)
43//! - **LRU (Least Recently Used)**: Evict least-recently-accessed clusters
44//! - **LFU (Least Frequently Used)**: Evict least-frequently-accessed clusters
45//! - **Dual-cache**: Combines LRU (local) + LFU (global) caches
46//!
47//! # Memory Paradigms: Heuristic vs Neural
48//!
49//! This implementation uses **heuristic** entity memory. There are three main
50//! paradigms in the literature:
51//!
52//! | Paradigm | Memory Update | Training | Systems |
53//! |----------|---------------|----------|---------|
54//! | **Heuristic** (this) | String match + discrete ops | None | Anno EntityMemory |
55//! | **Referential Reader** | GRU gates | End-to-end | Liu et al. 2019 |
56//! | **SpanEIT** | GRU per coref cluster | Supervised | Hossain et al. 2025 |
57//!
58//! **Why heuristic?**
59//! - No training data required
60//! - Fast inference (no neural forward pass)
61//! - Interpretable decisions
62//! - Useful as a baseline and as plumbing for eval harnesses
63//!
64//! **When to consider neural:**
65//! - Need to handle ambiguous mentions ("the president" → multiple candidates)
66//! - Working with languages where string match is unreliable
67//! - Building a trained coreference system
68//!
69//! References:
70//! - Liu, Zettlemoyer & Eisenstein (2019): "The Referential Reader" - ACL 2019
71//! - Hossain et al. (2025): "SpanEIT" - arXiv:2509.11604
72//!
73//! # Example
74//!
75//! ```rust
76//! use anno_eval::eval::incremental_coref::{IncrementalCorefResolver, IncrementalConfig, MemoryPolicy};
77//! use anno::Entity;
78//!
79//! let config = IncrementalConfig {
80//!     window_size: 1500,
81//!     window_overlap: 200,
82//!     memory_policy: MemoryPolicy::Unbounded,
83//!     similarity_threshold: 0.7,
84//!     ..Default::default()
85//! };
86//!
87//! let resolver = IncrementalCorefResolver::new(config);
88//!
89//! // Process a book incrementally
90//! let book_text = "John went to the store. He bought milk..."; // 200k+ tokens
91//! let clusters = resolver.resolve_document(book_text);
92//! ```
93//!
94//! # References
95//!
96//! - Toshniwal et al. (2020): "Learning to Ignore: Long Document Coreference
97//!   with Bounded Memory Neural Networks"
98//! - Toshniwal et al. (2021): "On Generalization in Coreference Resolution"
99//! - Guo et al. (2023): "Dual Cache for Long Document Neural Coreference Resolution"
100//! - Martinelli et al. (2025): "BOOKCOREF: Coreference Resolution at Book Scale"
101
102use super::coref::{CorefChain, Mention, MentionType};
103use anno::{Entity, EntityType};
104use serde::{Deserialize, Serialize};
105use std::collections::{HashMap, VecDeque};
106
107// =============================================================================
108// Configuration
109// =============================================================================
110
111/// Memory eviction policy for incremental coreference.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
113pub enum MemoryPolicy {
114    /// Keep all clusters (unbounded memory, most accurate)
115    #[default]
116    Unbounded,
117    /// Least Recently Used - evict clusters not accessed recently
118    LeastRecentlyUsed {
119        /// Maximum clusters to keep
120        max_clusters: usize,
121    },
122    /// Least Frequently Used - evict clusters with fewest accesses
123    LeastFrequentlyUsed {
124        /// Maximum clusters to keep
125        max_clusters: usize,
126    },
127    /// Dual-cache: L-cache (LRU) + G-cache (LFU)
128    ///
129    /// From Guo et al. (2023), combines local recency with global frequency.
130    DualCache {
131        /// L-cache (local, LRU) size
132        l_cache_size: usize,
133        /// G-cache (global, LFU) size
134        g_cache_size: usize,
135    },
136}
137
138/// Configuration for incremental coreference resolution.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct IncrementalConfig {
141    /// Window size in tokens (characters if token_based=false)
142    pub window_size: usize,
143    /// Overlap between consecutive windows (helps maintain continuity)
144    pub window_overlap: usize,
145    /// Memory eviction policy
146    pub memory_policy: MemoryPolicy,
147    /// Similarity threshold for linking mentions to existing clusters
148    pub similarity_threshold: f64,
149    /// Use token-based windowing (vs character-based)
150    pub token_based: bool,
151    /// Maximum distance (in windows) for pronoun antecedent search
152    pub max_pronoun_search_windows: usize,
153    /// Whether to use exact string matching as a strong signal
154    pub use_exact_match: bool,
155    /// Whether to use substring matching for names
156    pub use_substring_match: bool,
157    /// Group consecutive windows for second-pass expansion
158    /// (from BOOKCOREF pipeline, typically G=10)
159    pub grouped_window_size: usize,
160}
161
162impl Default for IncrementalConfig {
163    fn default() -> Self {
164        Self {
165            window_size: 1500,
166            window_overlap: 200,
167            memory_policy: MemoryPolicy::Unbounded,
168            similarity_threshold: 0.7,
169            token_based: true,
170            max_pronoun_search_windows: 3,
171            use_exact_match: true,
172            use_substring_match: true,
173            grouped_window_size: 10,
174        }
175    }
176}
177
178// =============================================================================
179// Entity Memory (Cluster Storage)
180// =============================================================================
181
182/// Metadata for a cluster in memory.
183#[derive(Debug, Clone)]
184struct ClusterMetadata {
185    /// Unique cluster ID
186    id: u64,
187    /// Representative mention text (typically the first or longest)
188    representative: String,
189    /// All mention texts in this cluster
190    mentions: Vec<MentionRecord>,
191    /// Last window index where this cluster was accessed
192    last_accessed_window: usize,
193    /// Total access count (for LFU policy)
194    access_count: usize,
195    /// Entity type (if known)
196    entity_type: Option<EntityType>,
197    /// Character offset of first mention (for ordering)
198    #[allow(dead_code)]
199    first_mention_offset: usize,
200}
201
202/// Record of a mention within a cluster.
203#[derive(Debug, Clone)]
204struct MentionRecord {
205    text: String,
206    start: usize,
207    end: usize,
208    #[allow(dead_code)]
209    window_index: usize,
210    mention_type: MentionType,
211}
212
213/// Entity memory for incremental coreference.
214///
215/// Stores active clusters and handles eviction based on memory policy.
216#[derive(Debug)]
217pub struct EntityMemory {
218    /// All clusters by ID
219    clusters: HashMap<u64, ClusterMetadata>,
220    /// Next cluster ID to assign
221    next_cluster_id: u64,
222    /// Memory policy
223    policy: MemoryPolicy,
224    /// Current window index
225    current_window: usize,
226    /// L-cache for dual-cache policy (cluster IDs in LRU order)
227    l_cache: VecDeque<u64>,
228    /// G-cache for dual-cache policy (cluster IDs in LFU order)
229    g_cache: Vec<u64>,
230}
231
232impl EntityMemory {
233    /// Create a new entity memory with the given policy.
234    pub fn new(policy: MemoryPolicy) -> Self {
235        Self {
236            clusters: HashMap::new(),
237            next_cluster_id: 0,
238            policy,
239            current_window: 0,
240            l_cache: VecDeque::new(),
241            g_cache: Vec::new(),
242        }
243    }
244
245    /// Create a new cluster with an initial mention.
246    fn create_cluster(&mut self, mention: &MentionRecord, entity_type: Option<EntityType>) -> u64 {
247        let id = self.next_cluster_id;
248        self.next_cluster_id += 1;
249
250        let cluster = ClusterMetadata {
251            id,
252            representative: mention.text.clone(),
253            mentions: vec![mention.clone()],
254            last_accessed_window: self.current_window,
255            access_count: 1,
256            entity_type,
257            first_mention_offset: mention.start,
258        };
259
260        self.clusters.insert(id, cluster);
261        self.update_cache_on_access(id);
262        self.maybe_evict();
263
264        id
265    }
266
267    /// Add a mention to an existing cluster.
268    fn add_to_cluster(&mut self, cluster_id: u64, mention: &MentionRecord) {
269        if let Some(cluster) = self.clusters.get_mut(&cluster_id) {
270            cluster.mentions.push(mention.clone());
271            cluster.last_accessed_window = self.current_window;
272            cluster.access_count += 1;
273
274            // Update representative if this mention is longer (better name)
275            if mention.text.len() > cluster.representative.len()
276                && mention.mention_type != MentionType::Pronominal
277            {
278                cluster.representative = mention.text.clone();
279            }
280        }
281        self.update_cache_on_access(cluster_id);
282    }
283
284    /// Find the best matching cluster for a mention.
285    pub fn find_best_match(
286        &self,
287        mention_text: &str,
288        mention_type: MentionType,
289        similarity_threshold: f64,
290        use_exact_match: bool,
291        use_substring_match: bool,
292    ) -> Option<u64> {
293        let mention_key = name_key(mention_text);
294        let mut best_match: Option<(u64, f64)> = None;
295
296        for (id, cluster) in &self.clusters {
297            // Skip if types are incompatible
298            if !self.types_compatible(mention_type, cluster) {
299                continue;
300            }
301
302            let score = self.compute_match_score(
303                &mention_key,
304                mention_type,
305                cluster,
306                use_exact_match,
307                use_substring_match,
308            );
309
310            if score >= similarity_threshold {
311                let should_update = match best_match {
312                    None => true,
313                    Some((_, best_score)) => score > best_score,
314                };
315                if should_update {
316                    best_match = Some((*id, score));
317                }
318            }
319        }
320
321        best_match.map(|(id, _)| id)
322    }
323
324    /// Compute match score between mention and cluster.
325    fn compute_match_score(
326        &self,
327        mention_key: &str,
328        mention_type: MentionType,
329        cluster: &ClusterMetadata,
330        use_exact_match: bool,
331        use_substring_match: bool,
332    ) -> f64 {
333        let rep_key = name_key(&cluster.representative);
334
335        // Exact match is highest confidence
336        if use_exact_match && mention_key == rep_key {
337            return 1.0;
338        }
339
340        // Check against all mentions in cluster
341        for m in &cluster.mentions {
342            let m_key = name_key(&m.text);
343            if mention_key == m_key {
344                return 0.95;
345            }
346        }
347
348        // Substring matching for proper names
349        if use_substring_match && mention_type != MentionType::Pronominal {
350            // "Smith" matches "John Smith"
351            if rep_key.contains(mention_key) || mention_key.contains(&rep_key) {
352                return 0.85;
353            }
354
355            // Check last name matching
356            let mention_parts: Vec<&str> = mention_key.split_whitespace().collect();
357            let rep_parts: Vec<&str> = rep_key.split_whitespace().collect();
358
359            if !mention_parts.is_empty() && !rep_parts.is_empty() {
360                // Last name match
361                if mention_parts.last() == rep_parts.last() {
362                    return 0.8;
363                }
364                // First name match
365                if mention_parts.first() == rep_parts.first() {
366                    return 0.75;
367                }
368            }
369        }
370
371        // Pronouns match based on gender compatibility (handled elsewhere)
372        if mention_type == MentionType::Pronominal {
373            return 0.6; // Base score for pronouns - caller should apply distance weighting
374        }
375
376        // Trigram similarity for fuzzy matching
377        let sim = trigram_similarity(mention_key, &rep_key);
378        sim * 0.7 // Scale down trigram similarity
379    }
380
381    /// Check if mention type is compatible with cluster.
382    fn types_compatible(&self, mention_type: MentionType, cluster: &ClusterMetadata) -> bool {
383        match cluster.entity_type {
384            Some(EntityType::Person) => {
385                // All mention types can refer to persons
386                true
387            }
388            Some(EntityType::Organization) | Some(EntityType::Location) => {
389                // Organizations/locations shouldn't have pronoun mentions (usually)
390                mention_type != MentionType::Pronominal
391            }
392            _ => true,
393        }
394    }
395
396    /// Update cache on cluster access.
397    fn update_cache_on_access(&mut self, cluster_id: u64) {
398        match self.policy {
399            MemoryPolicy::DualCache { l_cache_size, .. } => {
400                // Move to front of L-cache (most recently used)
401                self.l_cache.retain(|&id| id != cluster_id);
402                self.l_cache.push_front(cluster_id);
403
404                // Update position in G-cache based on frequency
405                if let Some(cluster) = self.clusters.get(&cluster_id) {
406                    let access_count = cluster.access_count;
407                    self.g_cache.retain(|&id| id != cluster_id);
408
409                    // Insert in sorted position (by access count, descending)
410                    let pos = self.g_cache.iter().position(|&id| {
411                        self.clusters
412                            .get(&id)
413                            .map(|c| c.access_count < access_count)
414                            .unwrap_or(true)
415                    });
416                    match pos {
417                        Some(p) => self.g_cache.insert(p, cluster_id),
418                        None => self.g_cache.push(cluster_id),
419                    }
420                }
421
422                // Trim L-cache
423                while self.l_cache.len() > l_cache_size {
424                    self.l_cache.pop_back();
425                }
426            }
427            MemoryPolicy::LeastRecentlyUsed { .. } => {
428                // Just track in clusters via last_accessed_window
429            }
430            MemoryPolicy::LeastFrequentlyUsed { .. } => {
431                // Just track in clusters via access_count
432            }
433            MemoryPolicy::Unbounded => {}
434        }
435    }
436
437    /// Evict clusters if over capacity.
438    fn maybe_evict(&mut self) {
439        match self.policy {
440            MemoryPolicy::LeastRecentlyUsed { max_clusters } => {
441                while self.clusters.len() > max_clusters {
442                    // Find LRU cluster
443                    let lru_id = self
444                        .clusters
445                        .iter()
446                        .min_by_key(|(_, c)| c.last_accessed_window)
447                        .map(|(&id, _)| id);
448
449                    if let Some(id) = lru_id {
450                        self.clusters.remove(&id);
451                    } else {
452                        break;
453                    }
454                }
455            }
456            MemoryPolicy::LeastFrequentlyUsed { max_clusters } => {
457                while self.clusters.len() > max_clusters {
458                    // Find LFU cluster
459                    let lfu_id = self
460                        .clusters
461                        .iter()
462                        .min_by_key(|(_, c)| c.access_count)
463                        .map(|(&id, _)| id);
464
465                    if let Some(id) = lfu_id {
466                        self.clusters.remove(&id);
467                    } else {
468                        break;
469                    }
470                }
471            }
472            MemoryPolicy::DualCache { g_cache_size, .. } => {
473                // Evict from G-cache when over capacity
474                while self.g_cache.len() > g_cache_size {
475                    if let Some(id) = self.g_cache.pop() {
476                        // Only remove from clusters if not in L-cache
477                        if !self.l_cache.contains(&id) {
478                            self.clusters.remove(&id);
479                        }
480                    }
481                }
482            }
483            MemoryPolicy::Unbounded => {}
484        }
485    }
486
487    /// Advance to next window.
488    pub fn advance_window(&mut self) {
489        self.current_window += 1;
490    }
491
492    /// Get all clusters as coreference chains.
493    pub fn to_chains(&self) -> Vec<CorefChain> {
494        self.clusters
495            .values()
496            .map(|cluster| {
497                let mentions: Vec<Mention> = cluster
498                    .mentions
499                    .iter()
500                    .map(|m| {
501                        let mut mention = Mention::new(&m.text, m.start, m.end);
502                        mention.mention_type = Some(m.mention_type);
503                        mention
504                    })
505                    .collect();
506
507                let mut chain = CorefChain::new(mentions);
508                chain.cluster_id = Some(cluster.id.into());
509                chain.entity_type = cluster.entity_type.as_ref().map(|t| format!("{:?}", t));
510                chain
511            })
512            .collect()
513    }
514
515    /// Get number of active clusters.
516    pub fn cluster_count(&self) -> usize {
517        self.clusters.len()
518    }
519
520    /// Get total mention count across all clusters.
521    pub fn mention_count(&self) -> usize {
522        self.clusters.values().map(|c| c.mentions.len()).sum()
523    }
524}
525
526// =============================================================================
527// Incremental Resolver
528// =============================================================================
529
530/// Incremental coreference resolver for book-scale documents.
531///
532/// Processes documents in windows, maintaining entity memory across windows.
533#[derive(Debug)]
534pub struct IncrementalCorefResolver {
535    config: IncrementalConfig,
536}
537
538impl Default for IncrementalCorefResolver {
539    fn default() -> Self {
540        Self::new(IncrementalConfig::default())
541    }
542}
543
544impl IncrementalCorefResolver {
545    /// Create a new incremental resolver with configuration.
546    pub fn new(config: IncrementalConfig) -> Self {
547        Self { config }
548    }
549
550    /// Resolve coreference for an entire document.
551    ///
552    /// Returns coreference chains linking all coreferent mentions.
553    pub fn resolve_document(&self, text: &str) -> Vec<CorefChain> {
554        let mut memory = EntityMemory::new(self.config.memory_policy);
555        let windows = self.split_into_windows(text);
556
557        for (window_idx, (window_text, window_offset)) in windows.iter().enumerate() {
558            // Extract mentions from this window
559            let mentions = self.extract_mentions(window_text, *window_offset);
560
561            // Link each mention to existing cluster or create new
562            for mention in mentions {
563                self.process_mention(&mut memory, &mention);
564            }
565
566            memory.advance_window();
567
568            // Optionally: grouped window expansion (BOOKCOREF pipeline)
569            if self.config.grouped_window_size > 0
570                && (window_idx + 1) % self.config.grouped_window_size == 0
571            {
572                self.expand_grouped_window(&mut memory, window_idx);
573            }
574        }
575
576        memory.to_chains()
577    }
578
579    /// Process a stream of entities (from external NER).
580    ///
581    /// Useful when NER is done separately and you just need coref linking.
582    pub fn resolve_entities(&self, entities: &[Entity]) -> Vec<Entity> {
583        let mut memory = EntityMemory::new(self.config.memory_policy);
584        let mut resolved = entities.to_vec();
585
586        // Group entities by approximate window
587        let mut current_window_start = 0usize;
588        let mut window_idx = 0;
589
590        for (i, entity) in entities.iter().enumerate() {
591            // Check if we've moved to a new window
592            if entity.start() >= current_window_start + self.config.window_size {
593                window_idx += 1;
594                current_window_start = entity.start().saturating_sub(self.config.window_overlap);
595                memory.advance_window();
596            }
597
598            let mention = MentionRecord {
599                text: entity.text.clone(),
600                start: entity.start(),
601                end: entity.end(),
602                window_index: window_idx,
603                mention_type: self.classify_mention_type(&entity.text),
604            };
605
606            let cluster_id = self.process_mention(&mut memory, &mention);
607            resolved[i].canonical_id = Some(cluster_id.into());
608        }
609
610        resolved
611    }
612
613    /// Split text into overlapping windows.
614    fn split_into_windows(&self, text: &str) -> Vec<(String, usize)> {
615        let mut windows = Vec::new();
616        let mut offset = 0;
617
618        if self.config.token_based {
619            // Token-based windowing, but offsets are still character offsets.
620            // We tokenize in a Unicode-safe way and keep (start_char, end_char) per token.
621            #[derive(Debug, Clone, Copy)]
622            struct TokenSpan {
623                start_char: usize,
624                end_char: usize,
625            }
626
627            fn tokenize_with_char_offsets(text: &str) -> Vec<TokenSpan> {
628                let mut tokens = Vec::new();
629
630                let mut in_word = false;
631                let mut word_start_char = 0;
632                let mut char_pos = 0;
633
634                for c in text.chars() {
635                    if c.is_whitespace() {
636                        if in_word {
637                            tokens.push(TokenSpan {
638                                start_char: word_start_char,
639                                end_char: char_pos,
640                            });
641                            in_word = false;
642                        }
643                    } else if !in_word {
644                        in_word = true;
645                        word_start_char = char_pos;
646                    }
647                    char_pos += 1;
648                }
649
650                if in_word {
651                    tokens.push(TokenSpan {
652                        start_char: word_start_char,
653                        end_char: char_pos,
654                    });
655                }
656
657                tokens
658            }
659
660            let tokens = tokenize_with_char_offsets(text);
661            let step = self
662                .config
663                .window_size
664                .saturating_sub(self.config.window_overlap);
665
666            while offset < tokens.len() {
667                let end = (offset + self.config.window_size).min(tokens.len());
668                if end == 0 || offset >= end {
669                    break;
670                }
671
672                let char_start = tokens[offset].start_char;
673                let char_end = tokens[end - 1].end_char;
674                let window_text = anno::offset::TextSpan::from_chars(text, char_start, char_end)
675                    .extract(text)
676                    .to_string();
677
678                windows.push((window_text, char_start));
679
680                if end >= tokens.len() {
681                    break;
682                }
683                offset += step.max(1);
684            }
685        } else {
686            // Character-based windowing
687            let text_char_len = text.chars().count();
688            let step = self
689                .config
690                .window_size
691                .saturating_sub(self.config.window_overlap);
692
693            while offset < text_char_len {
694                let end = (offset + self.config.window_size).min(text_char_len);
695
696                // Adjust to word boundary if possible.
697                // NOTE: `offset`/`end` are character offsets; we convert to byte offsets for rfind.
698                let adjusted_end = if end < text_char_len {
699                    let end_byte = anno::offset::TextSpan::from_chars(text, end, end).byte_start;
700                    let mut adjusted_end_byte = end_byte;
701
702                    if let Some(ws_byte_pos) = text[..end_byte].rfind(char::is_whitespace) {
703                        // Move past the whitespace character (not just +1 byte).
704                        let ws_len = text[ws_byte_pos..]
705                            .chars()
706                            .next()
707                            .map(|c| c.len_utf8())
708                            .unwrap_or(1);
709                        adjusted_end_byte = ws_byte_pos + ws_len;
710
711                        // Skip consecutive whitespace
712                        while adjusted_end_byte < end_byte {
713                            match text[adjusted_end_byte..].chars().next() {
714                                Some(c) if c.is_whitespace() => {
715                                    adjusted_end_byte += c.len_utf8();
716                                }
717                                _ => break,
718                            }
719                        }
720                    }
721
722                    let (adjusted_end_char, _) =
723                        anno::offset::bytes_to_chars(text, adjusted_end_byte, adjusted_end_byte);
724                    if adjusted_end_char > offset {
725                        adjusted_end_char.min(text_char_len)
726                    } else {
727                        end
728                    }
729                } else {
730                    end
731                };
732
733                let window_text = anno::offset::TextSpan::from_chars(text, offset, adjusted_end)
734                    .extract(text)
735                    .to_string();
736                windows.push((window_text, offset));
737
738                if adjusted_end >= text_char_len {
739                    break;
740                }
741                offset += step.max(1);
742            }
743        }
744
745        windows
746    }
747
748    /// Extract mentions from a window of text.
749    ///
750    /// This is a simplified mention detector. For production use,
751    /// integrate with a proper NER/mention detection model.
752    fn extract_mentions(&self, text: &str, offset: usize) -> Vec<MentionRecord> {
753        let mut mentions = Vec::new();
754
755        // Unicode-safe tokenization that preserves character offsets.
756        let mut in_word = false;
757        let mut word_start_byte = 0;
758        let mut word_start_char = 0;
759        let mut char_pos = 0;
760
761        let mut maybe_push_mention = |word: &str, local_start: usize, local_end: usize| {
762            let mention_type = self.classify_mention_type(word);
763
764            // Only track pronouns and capitalized words (potential names)
765            let should_track = match mention_type {
766                MentionType::Pronominal => true,
767                MentionType::Proper => true,
768                _ => word
769                    .chars()
770                    .next()
771                    .map(|c| c.is_uppercase())
772                    .unwrap_or(false),
773            };
774
775            if should_track {
776                mentions.push(MentionRecord {
777                    text: word.to_string(),
778                    start: offset + local_start,
779                    end: offset + local_end,
780                    window_index: 0, // Will be set by caller
781                    mention_type,
782                });
783            }
784        };
785
786        for (byte_idx, c) in text.char_indices() {
787            if c.is_whitespace() {
788                if in_word {
789                    let word = &text[word_start_byte..byte_idx];
790                    maybe_push_mention(word, word_start_char, char_pos);
791                    in_word = false;
792                }
793            } else if !in_word {
794                in_word = true;
795                word_start_byte = byte_idx;
796                word_start_char = char_pos;
797            }
798            char_pos += 1;
799        }
800
801        if in_word {
802            let word = &text[word_start_byte..];
803            maybe_push_mention(word, word_start_char, char_pos);
804        }
805
806        mentions
807    }
808
809    /// Classify mention type from text.
810    fn classify_mention_type(&self, text: &str) -> MentionType {
811        let lower = text.to_lowercase();
812
813        // Pronouns
814        let pronouns = [
815            "he",
816            "him",
817            "his",
818            "himself",
819            "she",
820            "her",
821            "hers",
822            "herself",
823            "they",
824            "them",
825            "their",
826            "theirs",
827            "themself",
828            "themselves",
829            "it",
830            "its",
831            "itself",
832            "i",
833            "me",
834            "my",
835            "mine",
836            "myself",
837            "we",
838            "us",
839            "our",
840            "ours",
841            "ourselves",
842            "you",
843            "your",
844            "yours",
845            "yourself",
846            "yourselves",
847            // Neopronouns
848            "xe",
849            "xem",
850            "xyr",
851            "xyrs",
852            "ze",
853            "zir",
854            "zirs",
855            "ey",
856            "em",
857            "eir",
858            "eirs",
859        ];
860
861        if pronouns.contains(&lower.as_str()) {
862            return MentionType::Pronominal;
863        }
864
865        // Proper nouns (capitalized, not sentence-initial)
866        if text
867            .chars()
868            .next()
869            .map(|c| c.is_uppercase())
870            .unwrap_or(false)
871        {
872            return MentionType::Proper;
873        }
874
875        // Definite descriptions ("the man", "the company")
876        if lower.starts_with("the ") {
877            return MentionType::Nominal;
878        }
879
880        MentionType::Unknown
881    }
882
883    /// Process a single mention, linking to existing cluster or creating new.
884    fn process_mention(&self, memory: &mut EntityMemory, mention: &MentionRecord) -> u64 {
885        // Try to find matching cluster
886        if let Some(cluster_id) = memory.find_best_match(
887            &mention.text,
888            mention.mention_type,
889            self.config.similarity_threshold,
890            self.config.use_exact_match,
891            self.config.use_substring_match,
892        ) {
893            memory.add_to_cluster(cluster_id, mention);
894            cluster_id
895        } else {
896            // Create new cluster
897            let entity_type = if mention.mention_type == MentionType::Proper {
898                Some(EntityType::Person) // Default assumption for proper nouns
899            } else {
900                None
901            };
902            memory.create_cluster(mention, entity_type)
903        }
904    }
905
906    /// Expand clusters within a grouped window (BOOKCOREF pipeline step).
907    ///
908    /// This is where mentions that couldn't be linked in individual windows
909    /// get a second chance with broader context.
910    fn expand_grouped_window(&self, memory: &mut EntityMemory, _window_idx: usize) {
911        // In a full implementation, this would:
912        // 1. Run a second-pass coref model on the grouped window
913        // 2. Use the broader context to resolve previously unlinked mentions
914        // 3. Merge singleton clusters that should be linked
915
916        // For now, we do simple cluster merging based on string similarity
917        let cluster_ids: Vec<u64> = memory.clusters.keys().copied().collect();
918
919        for i in 0..cluster_ids.len() {
920            for j in (i + 1)..cluster_ids.len() {
921                let id_i = cluster_ids[i];
922                let id_j = cluster_ids[j];
923
924                // Check if clusters should be merged
925                if self.should_merge_clusters(memory, id_i, id_j) {
926                    // Merge j into i
927                    if let Some(cluster_j) = memory.clusters.remove(&id_j) {
928                        if let Some(cluster_i) = memory.clusters.get_mut(&id_i) {
929                            cluster_i.mentions.extend(cluster_j.mentions);
930                            cluster_i.access_count += cluster_j.access_count;
931                        }
932                    }
933                }
934            }
935        }
936    }
937
938    /// Check if two clusters should be merged.
939    fn should_merge_clusters(&self, memory: &EntityMemory, id_a: u64, id_b: u64) -> bool {
940        let (cluster_a, cluster_b) = match (memory.clusters.get(&id_a), memory.clusters.get(&id_b))
941        {
942            (Some(a), Some(b)) => (a, b),
943            _ => return false,
944        };
945
946        // Don't merge if different entity types
947        if cluster_a.entity_type.is_some()
948            && cluster_b.entity_type.is_some()
949            && cluster_a.entity_type != cluster_b.entity_type
950        {
951            return false;
952        }
953
954        // Check name similarity
955        let rep_a = name_key(&cluster_a.representative);
956        let rep_b = name_key(&cluster_b.representative);
957
958        // Exact match
959        if rep_a == rep_b {
960            return true;
961        }
962
963        // Substring match (e.g., "Smith" and "John Smith")
964        if rep_a.contains(&rep_b) || rep_b.contains(&rep_a) {
965            return true;
966        }
967
968        // High trigram similarity
969        if trigram_similarity(&rep_a, &rep_b) > 0.8 {
970            return true;
971        }
972
973        false
974    }
975}
976
977// =============================================================================
978// Utility Functions
979// =============================================================================
980
981/// Normalize a name / mention surface into a comparison key.
982///
983/// This is intentionally **comparison-only** normalization (not offset-preserving): it is safe for
984/// coreference linking because it does not modify stored spans; it only affects matching decisions.
985fn name_key(s: &str) -> String {
986    use std::borrow::Cow;
987
988    // Remove BOM only. We intentionally do *not* strip other zero-width characters here because
989    // ZWJ/ZWNJ can be orthographically meaningful in multiple scripts.
990    fn strip_bom_only(s: &str) -> Cow<'_, str> {
991        if !s.chars().any(|c| c == '\u{FEFF}') {
992            return Cow::Borrowed(s);
993        }
994        Cow::Owned(s.chars().filter(|&c| c != '\u{FEFF}').collect())
995    }
996
997    let s = strip_bom_only(s);
998    let cfg = textprep::ScrubConfig {
999        normalize_newlines: true,
1000        remove_zero_width: false,
1001        remove_bidi_controls: true,
1002        collapse_whitespace: true,
1003        normalization: textprep::ScrubNormalization::Nfc,
1004        case: textprep::ScrubCase::Lower,
1005        strip_diacritics: false,
1006    };
1007    textprep::scrub_with(s.as_ref(), &cfg)
1008}
1009
1010/// Compute trigram (character 3-gram) similarity between two strings.
1011///
1012/// Returns Jaccard similarity of trigram sets.
1013fn trigram_similarity(a: &str, b: &str) -> f64 {
1014    if a.is_empty() || b.is_empty() {
1015        return 0.0;
1016    }
1017    // Preserve previous behavior: if either side yields no trigrams, similarity is 0.0.
1018    if a.chars().count() < 3 || b.chars().count() < 3 {
1019        return 0.0;
1020    }
1021    textprep::similarity::trigram_jaccard(a, b)
1022}
1023
1024// =============================================================================
1025// Statistics
1026// =============================================================================
1027
1028/// Statistics about incremental resolution.
1029#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1030pub struct IncrementalStats {
1031    /// Number of windows processed
1032    pub windows_processed: usize,
1033    /// Total mentions processed
1034    pub mentions_processed: usize,
1035    /// Final cluster count
1036    pub final_clusters: usize,
1037    /// Clusters evicted due to memory policy
1038    pub clusters_evicted: usize,
1039    /// Average mentions per cluster
1040    pub avg_mentions_per_cluster: f64,
1041    /// Maximum cluster size
1042    pub max_cluster_size: usize,
1043}
1044
1045impl IncrementalStats {
1046    /// Compute statistics from entity memory.
1047    pub fn from_memory(memory: &EntityMemory, windows: usize) -> Self {
1048        let cluster_sizes: Vec<usize> =
1049            memory.clusters.values().map(|c| c.mentions.len()).collect();
1050
1051        Self {
1052            windows_processed: windows,
1053            mentions_processed: memory.mention_count(),
1054            final_clusters: memory.cluster_count(),
1055            clusters_evicted: 0, // Would need tracking during resolution
1056            avg_mentions_per_cluster: if memory.cluster_count() > 0 {
1057                memory.mention_count() as f64 / memory.cluster_count() as f64
1058            } else {
1059                0.0
1060            },
1061            max_cluster_size: cluster_sizes.into_iter().max().unwrap_or(0),
1062        }
1063    }
1064}
1065
1066// =============================================================================
1067// Tests
1068// =============================================================================
1069
1070#[cfg(test)]
1071mod tests {
1072    use super::*;
1073
1074    #[test]
1075    fn test_windowing_and_mentions_use_character_offsets_on_unicode() {
1076        use anno::offset::TextSpan;
1077
1078        let config = IncrementalConfig {
1079            token_based: false,
1080            window_size: 12,
1081            window_overlap: 3,
1082            ..Default::default()
1083        };
1084        let resolver = IncrementalCorefResolver::new(config);
1085
1086        // Mixed-script + emoji prefix: byte offsets and char offsets diverge immediately.
1087        let text = "🎉 Dr. John went to 東京. He waved.";
1088
1089        let windows = resolver.split_into_windows(text);
1090        assert!(!windows.is_empty());
1091
1092        // Each window's offset is a char offset into the original text, and the window text must
1093        // equal the corresponding substring in the original text.
1094        for (window_text, window_offset) in &windows {
1095            let span = TextSpan::from_chars(
1096                text,
1097                *window_offset,
1098                *window_offset + window_text.chars().count(),
1099            );
1100            assert_eq!(span.extract(text), window_text);
1101        }
1102
1103        // Mention extraction should produce character offsets that are Unicode-safe.
1104        let mentions = resolver.extract_mentions(text, 0);
1105        let john = mentions
1106            .iter()
1107            .find(|m| m.text == "John")
1108            .expect("expected to detect 'John'");
1109        let extracted = TextSpan::from_chars(text, john.start, john.end).extract(text);
1110        assert_eq!(extracted, "John");
1111        assert_eq!(
1112            john.start, 6,
1113            "🎉(1) + space(1) + Dr.(2) + .(1) + space(1) = 6"
1114        );
1115        assert_eq!(john.end, 10);
1116    }
1117
1118    #[test]
1119    fn test_trigram_similarity() {
1120        assert!((trigram_similarity("hello", "hello") - 1.0).abs() < 0.001);
1121        assert!(trigram_similarity("hello", "world") < 0.3);
1122        assert!(trigram_similarity("john", "johnson") > 0.3);
1123        assert!(trigram_similarity("", "hello") == 0.0);
1124    }
1125
1126    #[test]
1127    fn test_entity_memory_basic() {
1128        let mut memory = EntityMemory::new(MemoryPolicy::Unbounded);
1129
1130        let mention1 = MentionRecord {
1131            text: "John Smith".to_string(),
1132            start: 0,
1133            end: 10,
1134            window_index: 0,
1135            mention_type: MentionType::Proper,
1136        };
1137
1138        let cluster_id = memory.create_cluster(&mention1, Some(EntityType::Person));
1139        assert_eq!(memory.cluster_count(), 1);
1140
1141        let mention2 = MentionRecord {
1142            text: "Smith".to_string(),
1143            start: 50,
1144            end: 55,
1145            window_index: 0,
1146            mention_type: MentionType::Proper,
1147        };
1148
1149        memory.add_to_cluster(cluster_id, &mention2);
1150        assert_eq!(memory.mention_count(), 2);
1151    }
1152
1153    #[test]
1154    fn test_entity_memory_lru_eviction() {
1155        let mut memory = EntityMemory::new(MemoryPolicy::LeastRecentlyUsed { max_clusters: 2 });
1156
1157        let mention1 = MentionRecord {
1158            text: "John".to_string(),
1159            start: 0,
1160            end: 4,
1161            window_index: 0,
1162            mention_type: MentionType::Proper,
1163        };
1164        memory.create_cluster(&mention1, None);
1165
1166        memory.advance_window();
1167
1168        let mention2 = MentionRecord {
1169            text: "Mary".to_string(),
1170            start: 10,
1171            end: 14,
1172            window_index: 1,
1173            mention_type: MentionType::Proper,
1174        };
1175        memory.create_cluster(&mention2, None);
1176
1177        memory.advance_window();
1178
1179        let mention3 = MentionRecord {
1180            text: "Bob".to_string(),
1181            start: 20,
1182            end: 23,
1183            window_index: 2,
1184            mention_type: MentionType::Proper,
1185        };
1186        memory.create_cluster(&mention3, None);
1187
1188        // Should have evicted the oldest (John)
1189        assert_eq!(memory.cluster_count(), 2);
1190    }
1191
1192    #[test]
1193    fn test_find_best_match() {
1194        let mut memory = EntityMemory::new(MemoryPolicy::Unbounded);
1195
1196        let mention1 = MentionRecord {
1197            text: "John Smith".to_string(),
1198            start: 0,
1199            end: 10,
1200            window_index: 0,
1201            mention_type: MentionType::Proper,
1202        };
1203        memory.create_cluster(&mention1, Some(EntityType::Person));
1204
1205        // Should match "Smith" to "John Smith"
1206        let match_result = memory.find_best_match("Smith", MentionType::Proper, 0.7, true, true);
1207        assert!(match_result.is_some());
1208    }
1209
1210    #[test]
1211    fn test_incremental_resolver_basic() {
1212        let config = IncrementalConfig {
1213            window_size: 100,
1214            window_overlap: 20,
1215            token_based: false,
1216            ..Default::default()
1217        };
1218
1219        let resolver = IncrementalCorefResolver::new(config);
1220
1221        let text = "John went to the store. He bought milk. John came home.";
1222        let chains = resolver.resolve_document(text);
1223
1224        // Should have at least one chain for John/He
1225        assert!(!chains.is_empty());
1226    }
1227
1228    #[test]
1229    fn test_resolve_entities() {
1230        let resolver = IncrementalCorefResolver::default();
1231
1232        let entities = vec![
1233            Entity::new("John Smith", EntityType::Person, 0, 10, 0.9),
1234            Entity::new("Smith", EntityType::Person, 50, 55, 0.85),
1235            Entity::new("he", EntityType::Person, 100, 102, 0.7),
1236        ];
1237
1238        let resolved = resolver.resolve_entities(&entities);
1239
1240        // First two should share a canonical_id (John Smith ~ Smith)
1241        assert_eq!(resolved[0].canonical_id, resolved[1].canonical_id);
1242    }
1243
1244    #[test]
1245    fn test_mention_type_classification() {
1246        let resolver = IncrementalCorefResolver::default();
1247
1248        assert_eq!(
1249            resolver.classify_mention_type("he"),
1250            MentionType::Pronominal
1251        );
1252        assert_eq!(
1253            resolver.classify_mention_type("she"),
1254            MentionType::Pronominal
1255        );
1256        assert_eq!(
1257            resolver.classify_mention_type("they"),
1258            MentionType::Pronominal
1259        );
1260        assert_eq!(
1261            resolver.classify_mention_type("xe"),
1262            MentionType::Pronominal
1263        );
1264        assert_eq!(resolver.classify_mention_type("John"), MentionType::Proper);
1265    }
1266
1267    #[test]
1268    fn test_dual_cache_policy() {
1269        let memory = EntityMemory::new(MemoryPolicy::DualCache {
1270            l_cache_size: 5,
1271            g_cache_size: 10,
1272        });
1273        assert_eq!(memory.cluster_count(), 0);
1274    }
1275}