Skip to main content

anno_eval/eval/
cdcr.rs

1//! Cross-Document Coreference Resolution (CDCR).
2//!
3//! # Overview
4//!
5//! CDCR extends within-document coreference to link mentions of the same
6//! entity (or event) *across* multiple documents. This is essential for:
7//!
8//! - Knowledge base population from document collections
9//! - Multi-document summarization
10//! - Event tracking across news articles
11//! - Entity linking at corpus scale
12//!
13//! # Key Challenges
14//!
15//! | Challenge | Example | Solution |
16//! |-----------|---------|----------|
17//! | **Scale** | Millions of mentions | LSH blocking |
18//! | **Ambiguity** | "John Smith" in 100 docs | Entity clustering |
19//! | **Context loss** | Pronouns across docs | Anchor to nominals |
20//!
21//! # Research Background
22//!
23//! Key papers in CDCR:
24//! - Cai & Strube (2010): End-to-end CDCR for entities
25//! - Barhom et al. (2019): Event CDCR with cross-document clustering
26//! - Caciularu et al. (2021): CDCR with transformers (ECB+)
27//!
28//! # Architecture
29//!
30//! ```text
31//! Documents → [Within-Doc Coref] → Entity Clusters per Doc
32//!                                        ↓
33//!                                [LSH Blocking]
34//!                                        ↓
35//!                              [Cross-Doc Clustering]
36//!                                        ↓
37//!                               Unified Entity KB
38//! ```
39//!
40//! # Example
41//!
42//! ```rust
43//! use anno_eval::eval::cdcr::{CDCRResolver, Document, CrossDocCluster};
44//!
45//! let docs = vec![
46//!     Document::new("doc1", "Jensen Huang announced Nvidia's new chips."),
47//!     Document::new("doc2", "The CEO of Nvidia revealed expansion plans."),
48//! ];
49//!
50//! let resolver = CDCRResolver::new();
51//! let clusters = resolver.resolve(&docs);
52//!
53//! // Jensen Huang and "The CEO of Nvidia" should be in the same cluster
54//! ```
55
56use crate::eval::coref::CorefChain;
57use anno::{Entity, EntityCategory, EntityType};
58use serde::{Deserialize, Serialize};
59use std::collections::{HashMap, HashSet};
60
61// =============================================================================
62// Types
63// =============================================================================
64
65/// A document with its text and extracted entities.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct Document {
68    /// Document identifier
69    pub id: String,
70    /// Full document text
71    pub text: String,
72    /// Entities extracted from this document
73    pub entities: Vec<Entity>,
74    /// Within-document coreference chains (if resolved)
75    pub coref_chains: Vec<CorefChain>,
76}
77
78impl Document {
79    /// Create a new document.
80    #[must_use]
81    pub fn new(id: &str, text: &str) -> Self {
82        Self {
83            id: id.to_string(),
84            text: text.to_string(),
85            entities: Vec::new(),
86            coref_chains: Vec::new(),
87        }
88    }
89
90    /// Add entities to the document.
91    #[must_use]
92    pub fn with_entities(mut self, entities: Vec<Entity>) -> Self {
93        self.entities = entities;
94        self
95    }
96
97    /// Add coreference chains.
98    #[must_use]
99    pub fn with_coref(mut self, chains: Vec<CorefChain>) -> Self {
100        self.coref_chains = chains;
101        self
102    }
103
104    /// Get all mentions (entities + chain mentions).
105    #[must_use]
106    pub fn all_mentions(&self) -> Vec<MentionRef> {
107        let mut mentions = Vec::new();
108
109        for (idx, entity) in self.entities.iter().enumerate() {
110            mentions.push(MentionRef {
111                doc_id: self.id.clone(),
112                entity_idx: idx,
113                text: entity.text.clone(),
114                entity_type: entity.entity_type.clone(),
115                within_doc_cluster: entity.canonical_id.map(|c| c.get()),
116            });
117        }
118
119        mentions
120    }
121}
122
123/// A reference to a mention within a document.
124#[derive(Debug, Clone)]
125pub struct MentionRef {
126    /// Document ID
127    pub doc_id: String,
128    /// Index in document's entity list
129    pub entity_idx: usize,
130    /// Mention text
131    pub text: String,
132    /// Entity type
133    pub entity_type: EntityType,
134    /// Within-document cluster ID (if resolved)
135    pub within_doc_cluster: Option<u64>,
136}
137
138/// A cross-document entity cluster.
139///
140/// Groups mentions from multiple documents that refer to the same
141/// real-world entity.
142#[derive(Debug, Clone, Default, Serialize, Deserialize)]
143pub struct CrossDocCluster {
144    /// Cluster ID
145    pub id: u64,
146    /// Canonical name for the cluster (e.g., "Jensen Huang")
147    pub canonical_name: String,
148    /// Entity type for the cluster
149    pub entity_type: Option<EntityType>,
150    /// Documents containing mentions of this entity
151    pub documents: Vec<String>,
152    /// (doc_id, entity_idx) pairs for all mentions
153    pub mentions: Vec<(String, usize)>,
154    /// External knowledge base ID (if linked)
155    pub kb_id: Option<String>,
156    /// Confidence in this clustering
157    pub confidence: f64,
158}
159
160impl CrossDocCluster {
161    /// Create a new cluster.
162    #[must_use]
163    pub fn new(id: impl Into<u64>, canonical_name: &str) -> Self {
164        Self {
165            id: id.into(),
166            canonical_name: canonical_name.to_string(),
167            entity_type: None,
168            documents: Vec::new(),
169            mentions: Vec::new(),
170            kb_id: None,
171            confidence: 1.0,
172        }
173    }
174
175    /// Number of mentions.
176    #[must_use]
177    pub fn len(&self) -> usize {
178        self.mentions.len()
179    }
180
181    /// Alias for `len()`.
182    #[must_use]
183    pub fn mention_count(&self) -> usize {
184        self.len()
185    }
186
187    /// Check if cluster is empty.
188    #[must_use]
189    pub fn is_empty(&self) -> bool {
190        self.mentions.is_empty()
191    }
192
193    /// Number of unique documents.
194    #[must_use]
195    pub fn doc_count(&self) -> usize {
196        self.documents.iter().collect::<HashSet<_>>().len()
197    }
198
199    /// Add a mention to the cluster.
200    pub fn add_mention(&mut self, doc_id: &str, entity_idx: usize) {
201        if !self.documents.contains(&doc_id.to_string()) {
202            self.documents.push(doc_id.to_string());
203        }
204        self.mentions.push((doc_id.to_string(), entity_idx));
205    }
206
207    /// Set entity type.
208    #[must_use]
209    pub fn with_type(mut self, entity_type: EntityType) -> Self {
210        self.entity_type = Some(entity_type);
211        self
212    }
213}
214
215// =============================================================================
216// Conversion Helpers
217// =============================================================================
218
219/// Convert a CrossDocCluster to an Identity.
220///
221/// This converts from the evaluation/clustering result format to
222/// the core representation.
223///
224/// # Note on TrackRefs
225///
226/// CDCR's `CrossDocCluster` doesn't contain track information (only entity indices),
227/// so we cannot create valid `TrackRef`s. The source is set to `None` to indicate
228/// this is a conversion from evaluation format. If you need proper TrackRefs,
229/// use `Corpus::resolve_inter_doc_coref()` instead.
230///
231/// # Note on `source: None`
232///
233/// The `source` field is set to `None` because `CrossDocCluster` only contains
234/// `(doc_id, entity_idx)` pairs, not `track_id`s. Without track IDs, we cannot
235/// create valid `TrackRef`s that would be needed for `IdentitySource::CrossDocCoref`.
236///
237/// This is intentional: identities created from evaluation data don't have
238/// the same provenance tracking as identities created through the normal
239/// corpus resolution pipeline.
240impl From<&CrossDocCluster> for anno::Identity {
241    fn from(cluster: &CrossDocCluster) -> Self {
242        Self {
243            id: anno::IdentityId::new(cluster.id),
244            canonical_name: cluster.canonical_name.clone(),
245            entity_type: cluster
246                .entity_type
247                .as_ref()
248                .map(|t| anno::TypeLabel::from(t.as_label())),
249            kb_id: cluster.kb_id.clone(),
250            kb_name: None,
251            description: None,
252            embedding: None,
253            aliases: Vec::new(),
254            confidence: anno::Confidence::new(cluster.confidence),
255            source: None, // Cannot determine source from CDCR format (no track_ids)
256        }
257    }
258}
259
260// =============================================================================
261// LSH Blocking
262// =============================================================================
263
264/// Locality-Sensitive Hashing (LSH) for blocking.
265///
266/// Blocking reduces the O(n²) pairwise comparison problem by grouping
267/// likely-coreferent mentions into "blocks" using hash signatures.
268///
269/// # Algorithm
270///
271/// 1. Compute a character n-gram signature for each mention
272/// 2. Hash the signature using multiple hash functions
273/// 3. Mentions with identical hash in any band are candidates
274///
275/// This achieves sub-quadratic scaling while maintaining high recall
276/// for truly coreferent pairs.
277#[derive(Debug, Clone)]
278pub struct LSHBlocker {
279    /// Number of hash bands
280    pub num_bands: usize,
281    /// Rows per band
282    pub rows_per_band: usize,
283    /// N-gram size for signatures
284    pub ngram_size: usize,
285}
286
287impl Default for LSHBlocker {
288    fn default() -> Self {
289        Self {
290            num_bands: 5,
291            rows_per_band: 3,
292            ngram_size: 3,
293        }
294    }
295}
296
297impl LSHBlocker {
298    /// Create a new LSH blocker.
299    #[must_use]
300    pub fn new(num_bands: usize, rows_per_band: usize) -> Self {
301        Self {
302            num_bands,
303            rows_per_band,
304            ngram_size: 3,
305        }
306    }
307
308    /// Compute candidate pairs for a set of mentions.
309    ///
310    /// Returns pairs of indices (i, j) where i < j that are candidates
311    /// for cross-document coreference.
312    #[must_use]
313    pub fn candidate_pairs(&self, mentions: &[MentionRef]) -> Vec<(usize, usize)> {
314        let signatures: Vec<Vec<u64>> = mentions
315            .iter()
316            .map(|m| self.compute_signature(&m.text))
317            .collect();
318
319        // For each band, group mentions by their band hash
320        let mut candidates: HashSet<(usize, usize)> = HashSet::new();
321
322        for band in 0..self.num_bands {
323            let mut buckets: HashMap<u64, Vec<usize>> = HashMap::new();
324
325            for (idx, sig) in signatures.iter().enumerate() {
326                let band_hash = self.band_hash(sig, band);
327                buckets.entry(band_hash).or_default().push(idx);
328            }
329
330            // All pairs in the same bucket are candidates
331            for indices in buckets.values() {
332                for i in 0..indices.len() {
333                    for j in (i + 1)..indices.len() {
334                        let (a, b) = if indices[i] < indices[j] {
335                            (indices[i], indices[j])
336                        } else {
337                            (indices[j], indices[i])
338                        };
339                        candidates.insert((a, b));
340                    }
341                }
342            }
343        }
344
345        candidates.into_iter().collect()
346    }
347
348    /// Compute minhash signature for a mention text.
349    fn compute_signature(&self, text: &str) -> Vec<u64> {
350        let normalized = text.to_lowercase();
351        let ngrams = self.extract_ngrams(&normalized);
352
353        // Compute minhash for each row
354        let total_hashes = self.num_bands * self.rows_per_band;
355        let mut signature = vec![u64::MAX; total_hashes];
356
357        for ngram in ngrams {
358            for (h, sig_val) in signature.iter_mut().enumerate().take(total_hashes) {
359                let hash = self.hash_ngram(&ngram, h as u64);
360                if hash < *sig_val {
361                    *sig_val = hash;
362                }
363            }
364        }
365
366        signature
367    }
368
369    /// Extract character n-grams from text.
370    fn extract_ngrams(&self, text: &str) -> Vec<String> {
371        let chars: Vec<char> = text.chars().collect();
372        if chars.len() < self.ngram_size {
373            return vec![text.to_string()];
374        }
375
376        chars
377            .windows(self.ngram_size)
378            .map(|w| w.iter().collect())
379            .collect()
380    }
381
382    /// Hash an n-gram with a seed.
383    fn hash_ngram(&self, ngram: &str, seed: u64) -> u64 {
384        // Simple hash: FNV-1a variant
385        let mut hash: u64 = seed.wrapping_add(0xcbf29ce484222325);
386        for byte in ngram.bytes() {
387            hash ^= byte as u64;
388            hash = hash.wrapping_mul(0x100000001b3);
389        }
390        hash
391    }
392
393    /// Compute band hash from signature.
394    fn band_hash(&self, signature: &[u64], band: usize) -> u64 {
395        let start = band * self.rows_per_band;
396        let end = (start + self.rows_per_band).min(signature.len());
397
398        signature[start..end]
399            .iter()
400            .fold(0u64, |acc, &val| acc.wrapping_mul(31).wrapping_add(val))
401    }
402
403    /// Estimate Jaccard similarity from minhash signatures.
404    #[must_use]
405    pub fn signature_similarity(sig1: &[u64], sig2: &[u64]) -> f64 {
406        if sig1.len() != sig2.len() || sig1.is_empty() {
407            return 0.0;
408        }
409
410        let matches = sig1.iter().zip(sig2.iter()).filter(|(a, b)| a == b).count();
411        matches as f64 / sig1.len() as f64
412    }
413}
414
415// =============================================================================
416// CDCR Resolver
417// =============================================================================
418
419/// Configuration for CDCR.
420#[derive(Clone)]
421pub struct CDCRConfig {
422    /// Minimum similarity for clustering
423    pub min_similarity: f64,
424    /// Use LSH blocking (recommended for large corpora)
425    pub use_lsh: bool,
426    /// LSH configuration
427    pub lsh: LSHBlocker,
428    /// Require type match for clustering
429    pub require_type_match: bool,
430    /// Optional cluster encoder for learned similarity (when available)
431    /// If None, falls back to string similarity
432    #[cfg(feature = "eval")]
433    pub cluster_encoder: Option<std::sync::Arc<dyn crate::eval::cluster_encoder::ClusterEncoder>>,
434}
435
436impl std::fmt::Debug for CDCRConfig {
437    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
438        #[cfg(feature = "eval")]
439        {
440            f.debug_struct("CDCRConfig")
441                .field("min_similarity", &self.min_similarity)
442                .field("use_lsh", &self.use_lsh)
443                .field("lsh", &self.lsh)
444                .field("require_type_match", &self.require_type_match)
445                .field(
446                    "cluster_encoder",
447                    &self.cluster_encoder.as_ref().map(|_| "<encoder>"),
448                )
449                .finish()
450        }
451        #[cfg(not(feature = "eval"))]
452        {
453            f.debug_struct("CDCRConfig")
454                .field("min_similarity", &self.min_similarity)
455                .field("use_lsh", &self.use_lsh)
456                .field("lsh", &self.lsh)
457                .field("require_type_match", &self.require_type_match)
458                .finish()
459        }
460    }
461}
462
463impl Default for CDCRConfig {
464    fn default() -> Self {
465        Self {
466            min_similarity: 0.5,
467            use_lsh: true,
468            lsh: LSHBlocker::default(),
469            require_type_match: true,
470            #[cfg(feature = "eval")]
471            cluster_encoder: None,
472        }
473    }
474}
475
476/// Cross-Document Coreference Resolver.
477///
478/// Clusters entity mentions across multiple documents into unified
479/// clusters representing the same real-world entities.
480///
481/// # Algorithm
482///
483/// 1. **Blocking** (LSH): Generate candidate pairs from all mentions
484/// 2. **Comparison**: Compute similarity for candidate pairs (uses ClusterEncoder if available)
485/// 3. **Clustering**: Agglomerative clustering with single-link
486///
487/// # Scalability
488///
489/// With LSH blocking, CDCR scales to millions of mentions:
490/// - Without blocking: O(n²) comparisons
491/// - With blocking: O(n × average_block_size)
492///
493/// # Cluster Encoder Integration
494///
495/// When a `ClusterEncoder` is provided in the config, CDCR uses learned
496/// cluster embeddings for similarity scoring instead of string similarity.
497/// This enables more accurate cross-document linking based on semantic
498/// similarity rather than surface form matching.
499#[derive(Clone, Default)]
500pub struct CDCRResolver {
501    config: CDCRConfig,
502}
503
504impl std::fmt::Debug for CDCRResolver {
505    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
506        f.debug_struct("CDCRResolver")
507            .field("config", &self.config)
508            .finish()
509    }
510}
511
512impl CDCRResolver {
513    /// Create a new resolver.
514    #[must_use]
515    pub fn new() -> Self {
516        Self::default()
517    }
518
519    /// Create with configuration.
520    #[must_use]
521    pub fn with_config(config: CDCRConfig) -> Self {
522        Self { config }
523    }
524
525    /// Set cluster encoder for learned similarity scoring.
526    ///
527    /// When a cluster encoder is provided, CDCR will use learned embeddings
528    /// for similarity computation instead of string similarity. This enables
529    /// more accurate cross-document entity linking.
530    #[cfg(feature = "eval")]
531    #[must_use]
532    pub fn with_cluster_encoder(
533        mut self,
534        encoder: std::sync::Arc<dyn crate::eval::cluster_encoder::ClusterEncoder>,
535    ) -> Self {
536        self.config.cluster_encoder = Some(encoder);
537        self
538    }
539
540    /// Resolve cross-document coreference.
541    #[must_use]
542    pub fn resolve(&self, documents: &[Document]) -> Vec<CrossDocCluster> {
543        // 1. Collect all mentions
544        let mentions: Vec<MentionRef> = documents.iter().flat_map(|d| d.all_mentions()).collect();
545
546        if mentions.is_empty() {
547            return vec![];
548        }
549
550        // 2. Get candidate pairs
551        let candidates = if self.config.use_lsh {
552            self.config.lsh.candidate_pairs(&mentions)
553        } else {
554            // Brute force: all pairs
555            let n = mentions.len();
556            let mut pairs = Vec::new();
557            for i in 0..n {
558                for j in (i + 1)..n {
559                    pairs.push((i, j));
560                }
561            }
562            pairs
563        };
564
565        // 3. Compute similarities and cluster
566        let mut union_find: Vec<usize> = (0..mentions.len()).collect();
567
568        for (i, j) in candidates {
569            if self.should_cluster(&mentions[i], &mentions[j]) {
570                Self::union(&mut union_find, i, j);
571            }
572        }
573
574        // 4. Build clusters from union-find
575        let mut cluster_map: HashMap<usize, Vec<usize>> = HashMap::new();
576        for i in 0..mentions.len() {
577            let root = Self::find(&mut union_find, i);
578            cluster_map.entry(root).or_default().push(i);
579        }
580
581        // 5. Convert to CrossDocCluster
582        cluster_map
583            .into_iter()
584            .enumerate()
585            .map(|(cluster_idx, (_, member_indices))| {
586                let first = &mentions[member_indices[0]];
587                let mut cluster = CrossDocCluster::new(cluster_idx as u64, &first.text);
588                cluster.entity_type = Some(first.entity_type.clone());
589
590                for idx in member_indices {
591                    let m = &mentions[idx];
592                    cluster.add_mention(&m.doc_id, m.entity_idx);
593                }
594
595                cluster
596            })
597            .collect()
598    }
599
600    /// Check if two mentions should be clustered.
601    fn should_cluster(&self, a: &MentionRef, b: &MentionRef) -> bool {
602        // Type check
603        if self.config.require_type_match && a.entity_type != b.entity_type {
604            return false;
605        }
606
607        // Similarity check
608        let sim = self.mention_similarity(a, b);
609        sim >= self.config.min_similarity
610    }
611
612    /// Compute similarity between two mentions.
613    ///
614    /// Uses cluster encoder if available (learned embeddings), otherwise
615    /// falls back to string similarity (heuristic).
616    fn mention_similarity(&self, a: &MentionRef, b: &MentionRef) -> f64 {
617        #[cfg(feature = "eval")]
618        if let Some(ref encoder) = self.config.cluster_encoder {
619            // Convert mentions to LocalCluster format for encoding
620            // For single mentions, create a singleton cluster
621            use crate::eval::cluster_encoder::{ClusterMention, LocalCluster};
622
623            let cluster_a = {
624                let mut c = LocalCluster::new(0, 0);
625                c.add_mention(ClusterMention {
626                    start: 0,
627                    end: a.text.len(),
628                    text: a.text.clone(),
629                    context_id: 0,
630                });
631                c
632            };
633
634            let cluster_b = {
635                let mut c = LocalCluster::new(1, 0);
636                c.add_mention(ClusterMention {
637                    start: 0,
638                    end: b.text.len(),
639                    text: b.text.clone(),
640                    context_id: 0,
641                });
642                c
643            };
644
645            // Encode clusters
646            let emb_a = encoder.encode_cluster(&cluster_a, None);
647            let emb_b = encoder.encode_cluster(&cluster_b, None);
648
649            // Compute cosine similarity between embeddings
650            if emb_a.embedding.len() == emb_b.embedding.len() {
651                let dot: f32 = emb_a
652                    .embedding
653                    .iter()
654                    .zip(emb_b.embedding.iter())
655                    .map(|(x, y)| x * y)
656                    .sum();
657                let norm_a: f32 = emb_a.embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
658                let norm_b: f32 = emb_b.embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
659
660                if norm_a > 0.0 && norm_b > 0.0 {
661                    return (dot / (norm_a * norm_b)) as f64;
662                }
663            }
664        }
665
666        // Fallback to string similarity
667        anno::similarity::string_similarity(&a.text, &b.text)
668    }
669
670    /// Union-find: find root with path compression (iterative).
671    fn find(parent: &mut [usize], mut i: usize) -> usize {
672        // Find root
673        let mut root = i;
674        while parent[root] != root {
675            root = parent[root];
676        }
677        // Path compression
678        while parent[i] != root {
679            let next = parent[i];
680            parent[i] = root;
681            i = next;
682        }
683        root
684    }
685
686    /// Union-find: union two sets
687    fn union(parent: &mut [usize], i: usize, j: usize) {
688        let root_i = Self::find(parent, i);
689        let root_j = Self::find(parent, j);
690        if root_i != root_j {
691            parent[root_i] = root_j;
692        }
693    }
694}
695
696// =============================================================================
697// Metrics
698// =============================================================================
699
700/// CDCR evaluation metrics.
701#[derive(Debug, Clone, Default)]
702pub struct CDCRMetrics {
703    /// B³ precision
704    pub b_cubed_precision: f64,
705    /// B³ recall
706    pub b_cubed_recall: f64,
707    /// B³ F1
708    pub b_cubed_f1: f64,
709    /// Number of predicted clusters
710    pub num_pred_clusters: usize,
711    /// Number of gold clusters
712    pub num_gold_clusters: usize,
713}
714
715impl CDCRMetrics {
716    /// Compute B³ scores for CDCR.
717    ///
718    /// B³ (Bagga & Baldwin, 1998) is computed per-mention:
719    /// - Precision: fraction of cluster that shares the mention's gold class
720    /// - Recall: fraction of gold class that shares the mention's cluster
721    #[must_use]
722    pub fn compute(predicted: &[CrossDocCluster], gold: &[CrossDocCluster]) -> Self {
723        // Build mention → cluster mappings
724        let pred_map = Self::build_mention_map(predicted);
725        let gold_map = Self::build_mention_map(gold);
726
727        let all_mentions: HashSet<_> = pred_map.keys().chain(gold_map.keys()).cloned().collect();
728
729        if all_mentions.is_empty() {
730            return Self::default();
731        }
732
733        let mut total_precision = 0.0;
734        let mut total_recall = 0.0;
735
736        for mention in &all_mentions {
737            let pred_cluster = pred_map.get(mention);
738            let gold_cluster = gold_map.get(mention);
739
740            match (pred_cluster, gold_cluster) {
741                (Some(pred), Some(gold)) => {
742                    // Intersection of pred and gold clusters
743                    let intersection: HashSet<_> = pred.intersection(gold).collect();
744
745                    total_precision += intersection.len() as f64 / pred.len() as f64;
746                    total_recall += intersection.len() as f64 / gold.len() as f64;
747                }
748                _ => {
749                    // Mention only in one (precision or recall is 0)
750                }
751            }
752        }
753
754        let n = all_mentions.len() as f64;
755        let precision = total_precision / n;
756        let recall = total_recall / n;
757        let f1 = if precision + recall > 0.0 {
758            2.0 * precision * recall / (precision + recall)
759        } else {
760            0.0
761        };
762
763        Self {
764            b_cubed_precision: precision,
765            b_cubed_recall: recall,
766            b_cubed_f1: f1,
767            num_pred_clusters: predicted.len(),
768            num_gold_clusters: gold.len(),
769        }
770    }
771
772    /// Build mention → set of cluster-mates mapping.
773    fn build_mention_map(
774        clusters: &[CrossDocCluster],
775    ) -> HashMap<(String, usize), HashSet<(String, usize)>> {
776        let mut map = HashMap::new();
777
778        for cluster in clusters {
779            let cluster_set: HashSet<_> = cluster.mentions.iter().cloned().collect();
780
781            for mention in &cluster.mentions {
782                map.insert(mention.clone(), cluster_set.clone());
783            }
784        }
785
786        map
787    }
788}
789
790// =============================================================================
791// Sample Datasets
792// =============================================================================
793
794/// Generate sample tech news documents for CDCR testing.
795///
796/// These documents mention the same entities (companies, people, locations)
797/// across different news articles about AI and semiconductors.
798#[must_use]
799pub fn tech_news_dataset() -> Vec<Document> {
800    let mut docs = Vec::new();
801
802    // Article 1: Nvidia announcement
803    let mut doc1 = Document::new(
804        "tech_01",
805        "Jensen Huang announced that Nvidia will build new AI supercomputers. \
806         The chipmaker plans to expand its data center business.",
807    );
808    doc1.entities = vec![
809        Entity::new("Jensen Huang", EntityType::Person, 0, 12, 0.95),
810        Entity::new("Nvidia", EntityType::Organization, 28, 34, 0.94),
811    ];
812    docs.push(doc1);
813
814    // Article 2: Different perspective on same event
815    let mut doc2 = Document::new(
816        "tech_02",
817        "The CEO of Nvidia revealed plans for Blackwell chips during CES 2025. \
818         Huang said the new GPUs would advance robotics and autonomous systems.",
819    );
820    doc2.entities = vec![
821        Entity::new("CEO of Nvidia", EntityType::Person, 4, 17, 0.85),
822        Entity::new("Nvidia", EntityType::Organization, 11, 17, 0.9),
823        Entity::new(
824            "Blackwell",
825            EntityType::custom("Product", EntityCategory::Misc),
826            37,
827            46,
828            0.87,
829        ),
830        Entity::new(
831            "CES 2025",
832            EntityType::custom("Event", EntityCategory::Misc),
833            60,
834            68,
835            0.88,
836        ),
837        Entity::new("Huang", EntityType::Person, 70, 75, 0.92),
838    ];
839    docs.push(doc2);
840
841    // Article 3: AI industry context
842    let mut doc3 = Document::new(
843        "tech_03",
844        "Anthropic and Google DeepMind are competing with Nvidia for AI dominance. \
845         Dario Amodei spoke about AI safety priorities.",
846    );
847    doc3.entities = vec![
848        Entity::new("Anthropic", EntityType::Organization, 0, 9, 0.93),
849        Entity::new("Google DeepMind", EntityType::Organization, 14, 29, 0.92),
850        Entity::new("Nvidia", EntityType::Organization, 49, 55, 0.91),
851        Entity::new("Dario Amodei", EntityType::Person, 76, 88, 0.94),
852    ];
853    docs.push(doc3);
854
855    // Article 4: Follow-up on Nvidia
856    let mut doc4 = Document::new(
857        "tech_04",
858        "Nvidia's stock reached new highs after Jensen Huang's keynote. \
859         The company announced partnerships with major cloud providers.",
860    );
861    doc4.entities = vec![
862        Entity::new("Nvidia", EntityType::Organization, 0, 6, 0.94),
863        Entity::new("Jensen Huang", EntityType::Person, 38, 50, 0.93),
864    ];
865    docs.push(doc4);
866
867    // Article 5: Competitor mention
868    let mut doc5 = Document::new(
869        "tech_05",
870        "AMD and Intel responded to Nvidia's AI chip announcements. \
871         The semiconductor rivals are investing heavily in data center GPUs.",
872    );
873    doc5.entities = vec![
874        Entity::new("AMD", EntityType::Organization, 0, 3, 0.93),
875        Entity::new("Intel", EntityType::Organization, 8, 13, 0.91),
876        Entity::new("Nvidia", EntityType::Organization, 27, 33, 0.9),
877    ];
878    docs.push(doc5);
879
880    docs
881}
882
883/// Generate political news documents for CDCR testing.
884#[must_use]
885pub fn political_news_dataset() -> Vec<Document> {
886    let mut docs = Vec::new();
887
888    let mut doc1 = Document::new(
889        "pol_01",
890        "President Biden met with Chancellor Scholz in Washington. \
891         The two leaders discussed NATO expansion.",
892    );
893    doc1.entities = vec![
894        Entity::new("President Biden", EntityType::Person, 0, 14, 0.95),
895        Entity::new("Chancellor Scholz", EntityType::Person, 24, 41, 0.93),
896        Entity::new("Washington", EntityType::Location, 45, 55, 0.92),
897        Entity::new("NATO", EntityType::Organization, 84, 88, 0.94),
898    ];
899    docs.push(doc1);
900
901    let mut doc2 = Document::new(
902        "pol_02",
903        "Biden and Scholz signed a joint statement on security. \
904         The US President emphasized transatlantic unity.",
905    );
906    doc2.entities = vec![
907        Entity::new("Biden", EntityType::Person, 0, 5, 0.94),
908        Entity::new("Scholz", EntityType::Person, 10, 16, 0.92),
909        Entity::new("US President", EntityType::Person, 60, 72, 0.88),
910    ];
911    docs.push(doc2);
912
913    let mut doc3 = Document::new(
914        "pol_03",
915        "The German Chancellor held talks with the American President. \
916         Olaf Scholz flew back to Berlin after the summit.",
917    );
918    doc3.entities = vec![
919        Entity::new("German Chancellor", EntityType::Person, 4, 21, 0.9),
920        Entity::new("American President", EntityType::Person, 38, 56, 0.88),
921        Entity::new("Olaf Scholz", EntityType::Person, 58, 69, 0.93),
922        Entity::new("Berlin", EntityType::Location, 82, 88, 0.91),
923    ];
924    docs.push(doc3);
925
926    let mut doc4 = Document::new(
927        "pol_04",
928        "NATO Secretary General praised the Biden-Scholz meeting. \
929         The alliance is preparing for new challenges.",
930    );
931    doc4.entities = vec![
932        Entity::new("NATO Secretary General", EntityType::Person, 0, 22, 0.87),
933        Entity::new("Biden", EntityType::Person, 35, 40, 0.92),
934        Entity::new("Scholz", EntityType::Person, 41, 47, 0.91),
935        Entity::new("NATO", EntityType::Organization, 0, 4, 0.94),
936    ];
937    docs.push(doc4);
938
939    docs
940}
941
942/// Generate sports news documents for CDCR testing.
943#[must_use]
944pub fn sports_news_dataset() -> Vec<Document> {
945    let mut docs = Vec::new();
946
947    let mut doc1 = Document::new(
948        "sport_01",
949        "Lionel Messi scored twice as Inter Miami defeated Atlanta United 3-1. \
950         The Argentine superstar continues his MLS dominance.",
951    );
952    doc1.entities = vec![
953        Entity::new("Lionel Messi", EntityType::Person, 0, 12, 0.96),
954        Entity::new("Inter Miami", EntityType::Organization, 29, 40, 0.93),
955        Entity::new("Atlanta United", EntityType::Organization, 50, 64, 0.91),
956        Entity::new(
957            "Argentine",
958            EntityType::custom("Nationality", EntityCategory::Misc),
959            75,
960            84,
961            0.87,
962        ),
963    ];
964    docs.push(doc1);
965
966    let mut doc2 = Document::new(
967        "sport_02",
968        "Messi's brace helped Miami to victory. The former Barcelona star \
969         is in top form.",
970    );
971    doc2.entities = vec![
972        Entity::new("Messi", EntityType::Person, 0, 5, 0.95),
973        Entity::new("Miami", EntityType::Organization, 21, 26, 0.88),
974        Entity::new("Barcelona", EntityType::Organization, 49, 58, 0.91),
975    ];
976    docs.push(doc2);
977
978    let mut doc3 = Document::new(
979        "sport_03",
980        "Inter Miami's victory over Atlanta keeps them top of the table. \
981         Messi has 15 goals this season.",
982    );
983    doc3.entities = vec![
984        Entity::new("Inter Miami", EntityType::Organization, 0, 11, 0.92),
985        Entity::new("Atlanta", EntityType::Organization, 27, 34, 0.87),
986        Entity::new("Messi", EntityType::Person, 66, 71, 0.94),
987    ];
988    docs.push(doc3);
989
990    let mut doc4 = Document::new(
991        "sport_04",
992        "The Argentine forward Leo Messi broke another MLS record. \
993         Miami's number 10 is unstoppable.",
994    );
995    doc4.entities = vec![
996        Entity::new("Argentine forward", EntityType::Person, 4, 21, 0.85),
997        Entity::new("Leo Messi", EntityType::Person, 22, 31, 0.94),
998        Entity::new("MLS", EntityType::Organization, 46, 49, 0.9),
999        Entity::new("Miami", EntityType::Organization, 59, 64, 0.87),
1000    ];
1001    docs.push(doc4);
1002
1003    docs
1004}
1005
1006/// Generate financial news documents for CDCR testing.
1007#[must_use]
1008pub fn financial_news_dataset() -> Vec<Document> {
1009    let mut docs = Vec::new();
1010
1011    let mut doc1 = Document::new(
1012        "fin_01",
1013        "Apple reported record quarterly revenue of $117 billion. \
1014         Tim Cook said iPhone sales exceeded expectations.",
1015    );
1016    doc1.entities = vec![
1017        Entity::new("Apple", EntityType::Organization, 0, 5, 0.95),
1018        Entity::new("Tim Cook", EntityType::Person, 59, 67, 0.93),
1019        Entity::new(
1020            "iPhone",
1021            EntityType::custom("Product", EntityCategory::Misc),
1022            73,
1023            79,
1024            0.91,
1025        ),
1026    ];
1027    docs.push(doc1);
1028
1029    let mut doc2 = Document::new(
1030        "fin_02",
1031        "The iPhone maker's stock rose 5% after earnings beat. \
1032         Apple's CEO expressed confidence in services growth.",
1033    );
1034    doc2.entities = vec![
1035        Entity::new("iPhone maker", EntityType::Organization, 4, 16, 0.85),
1036        Entity::new("Apple", EntityType::Organization, 55, 60, 0.94),
1037        Entity::new("CEO", EntityType::Person, 63, 66, 0.8),
1038    ];
1039    docs.push(doc2);
1040
1041    let mut doc3 = Document::new(
1042        "fin_03",
1043        "Cook highlighted Apple's expansion in India. The Cupertino company \
1044         is reducing reliance on China.",
1045    );
1046    doc3.entities = vec![
1047        Entity::new("Cook", EntityType::Person, 0, 4, 0.91),
1048        Entity::new("Apple", EntityType::Organization, 17, 22, 0.94),
1049        Entity::new("India", EntityType::Location, 38, 43, 0.92),
1050        Entity::new("Cupertino company", EntityType::Organization, 49, 66, 0.82),
1051        Entity::new("China", EntityType::Location, 95, 100, 0.91),
1052    ];
1053    docs.push(doc3);
1054
1055    let mut doc4 = Document::new(
1056        "fin_04",
1057        "Microsoft and Google also reported strong results. \
1058         But Apple outperformed both tech rivals.",
1059    );
1060    doc4.entities = vec![
1061        Entity::new("Microsoft", EntityType::Organization, 0, 9, 0.94),
1062        Entity::new("Google", EntityType::Organization, 14, 20, 0.93),
1063        Entity::new("Apple", EntityType::Organization, 56, 61, 0.94),
1064    ];
1065    docs.push(doc4);
1066
1067    docs
1068}
1069
1070/// Generate scientific/research documents for CDCR testing.
1071#[must_use]
1072pub fn science_news_dataset() -> Vec<Document> {
1073    let mut docs = Vec::new();
1074
1075    let mut doc1 = Document::new(
1076        "sci_01",
1077        "NASA's Perseverance rover discovered organic molecules on Mars. \
1078         The Jezero Crater finding excited scientists.",
1079    );
1080    doc1.entities = vec![
1081        Entity::new("NASA", EntityType::Organization, 0, 4, 0.95),
1082        Entity::new(
1083            "Perseverance",
1084            EntityType::custom("Product", EntityCategory::Misc),
1085            7,
1086            19,
1087            0.92,
1088        ),
1089        Entity::new("Mars", EntityType::Location, 54, 58, 0.94),
1090        Entity::new("Jezero Crater", EntityType::Location, 64, 77, 0.89),
1091    ];
1092    docs.push(doc1);
1093
1094    let mut doc2 = Document::new(
1095        "sci_02",
1096        "The Mars rover collected samples that may contain biosignatures. \
1097         NASA plans to bring these samples to Earth.",
1098    );
1099    doc2.entities = vec![
1100        Entity::new(
1101            "Mars rover",
1102            EntityType::custom("Product", EntityCategory::Misc),
1103            4,
1104            14,
1105            0.87,
1106        ),
1107        Entity::new("NASA", EntityType::Organization, 66, 70, 0.94),
1108        Entity::new("Earth", EntityType::Location, 101, 106, 0.93),
1109    ];
1110    docs.push(doc2);
1111
1112    let mut doc3 = Document::new(
1113        "sci_03",
1114        "Perseverance has been operating in Jezero Crater since 2021. \
1115         The rover has traveled over 10 kilometers.",
1116    );
1117    doc3.entities = vec![
1118        Entity::new(
1119            "Perseverance",
1120            EntityType::custom("Product", EntityCategory::Misc),
1121            0,
1122            12,
1123            0.93,
1124        ),
1125        Entity::new("Jezero Crater", EntityType::Location, 35, 48, 0.9),
1126    ];
1127    docs.push(doc3);
1128
1129    let mut doc4 = Document::new(
1130        "sci_04",
1131        "ESA and NASA are collaborating on Mars Sample Return. \
1132         The European Space Agency will build the orbiter.",
1133    );
1134    doc4.entities = vec![
1135        Entity::new("ESA", EntityType::Organization, 0, 3, 0.92),
1136        Entity::new("NASA", EntityType::Organization, 8, 12, 0.94),
1137        Entity::new("Mars", EntityType::Location, 34, 38, 0.93),
1138        Entity::new(
1139            "European Space Agency",
1140            EntityType::Organization,
1141            59,
1142            80,
1143            0.91,
1144        ),
1145    ];
1146    docs.push(doc4);
1147
1148    docs
1149}
1150
1151/// Combined comprehensive CDCR dataset.
1152#[must_use]
1153pub fn comprehensive_cdcr_dataset() -> Vec<Document> {
1154    let mut docs = tech_news_dataset();
1155    docs.extend(political_news_dataset());
1156    docs.extend(sports_news_dataset());
1157    docs.extend(financial_news_dataset());
1158    docs.extend(science_news_dataset());
1159    docs
1160}
1161
1162// =============================================================================
1163// Tests
1164// =============================================================================
1165
1166#[cfg(test)]
1167mod tests {
1168    use super::*;
1169
1170    fn sample_documents() -> Vec<Document> {
1171        let mut doc1 = Document::new(
1172            "doc1",
1173            "Jensen Huang announced Nvidia's new AI chips in Santa Clara.",
1174        );
1175        doc1.entities = vec![
1176            Entity::new("Jensen Huang", EntityType::Person, 0, 12, 0.95),
1177            Entity::new("Nvidia", EntityType::Organization, 23, 29, 0.94),
1178            Entity::new("Santa Clara", EntityType::Location, 48, 59, 0.92),
1179        ];
1180
1181        let mut doc2 = Document::new(
1182            "doc2",
1183            "The CEO of Nvidia revealed data center expansion plans.",
1184        );
1185        doc2.entities = vec![
1186            Entity::new("CEO of Nvidia", EntityType::Person, 4, 17, 0.85),
1187            Entity::new("Nvidia", EntityType::Organization, 11, 17, 0.94),
1188        ];
1189
1190        let mut doc3 = Document::new(
1191            "doc3",
1192            "Huang spoke about Anthropic and the Santa Clara campus.",
1193        );
1194        doc3.entities = vec![
1195            Entity::new("Huang", EntityType::Person, 0, 5, 0.88),
1196            Entity::new("Anthropic", EntityType::Organization, 18, 27, 0.92),
1197            Entity::new("Santa Clara", EntityType::Location, 36, 47, 0.9),
1198        ];
1199
1200        vec![doc1, doc2, doc3]
1201    }
1202
1203    #[test]
1204    fn test_lsh_blocking() {
1205        // Test with very similar strings that should hash together
1206        let mentions = vec![
1207            MentionRef {
1208                doc_id: "d1".into(),
1209                entity_idx: 0,
1210                text: "Berlin Germany".into(),
1211                entity_type: EntityType::Location,
1212                within_doc_cluster: None,
1213            },
1214            MentionRef {
1215                doc_id: "d2".into(),
1216                entity_idx: 0,
1217                text: "Berlin Germany".into(), // Exact same text
1218                entity_type: EntityType::Location,
1219                within_doc_cluster: None,
1220            },
1221            MentionRef {
1222                doc_id: "d3".into(),
1223                entity_idx: 0,
1224                text: "New York".into(),
1225                entity_type: EntityType::Location,
1226                within_doc_cluster: None,
1227            },
1228        ];
1229
1230        let blocker = LSHBlocker::default();
1231        let candidates = blocker.candidate_pairs(&mentions);
1232
1233        // Identical strings should be candidates
1234        assert!(
1235            candidates.contains(&(0, 1)),
1236            "Identical texts should be candidate pairs"
1237        );
1238    }
1239
1240    #[test]
1241    fn test_cdcr_resolver() {
1242        let docs = sample_documents();
1243
1244        // Use brute-force comparison for this test (LSH may miss short strings)
1245        let config = CDCRConfig {
1246            use_lsh: false, // Brute force for reliable testing
1247            ..Default::default()
1248        };
1249        let resolver = CDCRResolver::with_config(config);
1250
1251        let clusters = resolver.resolve(&docs);
1252
1253        // Should have some clusters
1254        assert!(!clusters.is_empty(), "Should produce clusters");
1255
1256        // Find the exact "Nvidia" cluster (Organization type, not "CEO of Nvidia")
1257        // This cluster should span doc1 and doc2
1258        let nvidia_org_cluster = clusters.iter().find(|c| {
1259            c.canonical_name.to_lowercase() == "nvidia"
1260                && c.entity_type == Some(EntityType::Organization)
1261        });
1262
1263        assert!(
1264            nvidia_org_cluster.is_some(),
1265            "Should find Nvidia Organization cluster. Clusters: {:?}",
1266            clusters
1267                .iter()
1268                .map(|c| (&c.canonical_name, &c.entity_type, c.doc_count()))
1269                .collect::<Vec<_>>()
1270        );
1271
1272        let nc = nvidia_org_cluster.unwrap();
1273        assert!(
1274            nc.doc_count() >= 2,
1275            "Nvidia Org should appear in at least 2 documents, found {} docs. Mentions: {:?}",
1276            nc.doc_count(),
1277            nc.mentions
1278        );
1279    }
1280
1281    #[test]
1282    fn test_cdcr_same_entity_different_docs() {
1283        let mut doc1 = Document::new("doc1", "Barack Obama visited Berlin.");
1284        doc1.entities = vec![Entity::new("Barack Obama", EntityType::Person, 0, 12, 0.95)];
1285
1286        let mut doc2 = Document::new("doc2", "Obama gave a speech in Germany.");
1287        doc2.entities = vec![Entity::new("Obama", EntityType::Person, 0, 5, 0.9)];
1288
1289        // Disable LSH to use brute-force (ensures all pairs compared)
1290        let config = CDCRConfig {
1291            min_similarity: 0.3, // Lower threshold for substring match
1292            use_lsh: false,      // Brute force for small test
1293            ..Default::default()
1294        };
1295        let resolver = CDCRResolver::with_config(config);
1296        let clusters = resolver.resolve(&[doc1, doc2]);
1297
1298        // Obama mentions should be in the same cluster
1299        let obama_cluster = clusters
1300            .iter()
1301            .find(|c| c.canonical_name.to_lowercase().contains("obama"));
1302
1303        assert!(obama_cluster.is_some(), "Should find Obama cluster");
1304
1305        let cluster = obama_cluster.unwrap();
1306        assert_eq!(
1307            cluster.doc_count(),
1308            2,
1309            "Obama should appear in both documents"
1310        );
1311    }
1312
1313    #[test]
1314    fn test_cdcr_metrics() {
1315        // Simple test case: 2 entities, 2 clusters each
1316        let pred = vec![CrossDocCluster {
1317            id: 0,
1318            canonical_name: "Entity A".into(),
1319            entity_type: Some(EntityType::Person),
1320            documents: vec!["d1".into(), "d2".into()],
1321            mentions: vec![("d1".into(), 0), ("d2".into(), 0)],
1322            kb_id: None,
1323            confidence: 1.0,
1324        }];
1325
1326        let gold = vec![CrossDocCluster {
1327            id: 0,
1328            canonical_name: "Entity A".into(),
1329            entity_type: Some(EntityType::Person),
1330            documents: vec!["d1".into(), "d2".into()],
1331            mentions: vec![("d1".into(), 0), ("d2".into(), 0)],
1332            kb_id: None,
1333            confidence: 1.0,
1334        }];
1335
1336        let metrics = CDCRMetrics::compute(&pred, &gold);
1337
1338        assert!(
1339            (metrics.b_cubed_f1 - 1.0).abs() < 0.01,
1340            "Perfect clustering should have F1 = 1.0"
1341        );
1342    }
1343
1344    // =================================================================
1345    // Additional Edge Case Tests
1346    // =================================================================
1347
1348    #[test]
1349    fn test_empty_documents() {
1350        let resolver = CDCRResolver::new();
1351        let clusters = resolver.resolve(&[]);
1352        assert!(clusters.is_empty(), "Empty docs should produce no clusters");
1353    }
1354
1355    #[test]
1356    fn test_single_document() {
1357        let mut doc = Document::new("doc1", "John Smith works at Google.");
1358        doc.entities = vec![
1359            Entity::new("John Smith", EntityType::Person, 0, 10, 0.9),
1360            Entity::new("Google", EntityType::Organization, 20, 26, 0.95),
1361        ];
1362
1363        let config = CDCRConfig {
1364            use_lsh: false,
1365            ..Default::default()
1366        };
1367        let resolver = CDCRResolver::with_config(config);
1368        let clusters = resolver.resolve(&[doc]);
1369
1370        // Each entity should be in its own cluster
1371        assert_eq!(clusters.len(), 2, "Two entities should form two clusters");
1372    }
1373
1374    #[test]
1375    fn test_document_with_no_entities() {
1376        let doc = Document::new("doc1", "This is a test document without entities.");
1377        let resolver = CDCRResolver::new();
1378        let clusters = resolver.resolve(&[doc]);
1379        assert!(
1380            clusters.is_empty(),
1381            "Doc without entities should produce no clusters"
1382        );
1383    }
1384
1385    #[test]
1386    fn test_type_mismatch_prevents_clustering() {
1387        let mut doc1 = Document::new("doc1", "Apple announced new products.");
1388        doc1.entities = vec![Entity::new("Apple", EntityType::Organization, 0, 5, 0.9)];
1389
1390        let mut doc2 = Document::new("doc2", "I ate an apple for lunch.");
1391        doc2.entities = vec![Entity::new(
1392            "apple",
1393            EntityType::custom("Fruit", EntityCategory::Misc),
1394            9,
1395            14,
1396            0.8,
1397        )];
1398
1399        let config = CDCRConfig {
1400            use_lsh: false,
1401            require_type_match: true, // Strict type matching
1402            ..Default::default()
1403        };
1404        let resolver = CDCRResolver::with_config(config);
1405        let clusters = resolver.resolve(&[doc1, doc2]);
1406
1407        // Should have 2 separate clusters due to type mismatch
1408        assert_eq!(clusters.len(), 2, "Type mismatch should prevent clustering");
1409    }
1410
1411    #[test]
1412    fn test_similarity_threshold() {
1413        let mut doc1 = Document::new("doc1", "John works here.");
1414        doc1.entities = vec![Entity::new("John", EntityType::Person, 0, 4, 0.9)];
1415
1416        let mut doc2 = Document::new("doc2", "Jonathan is a developer.");
1417        doc2.entities = vec![Entity::new("Jonathan", EntityType::Person, 0, 8, 0.9)];
1418
1419        // High threshold - should NOT cluster
1420        let config_high = CDCRConfig {
1421            use_lsh: false,
1422            min_similarity: 0.9,
1423            ..Default::default()
1424        };
1425        let resolver_high = CDCRResolver::with_config(config_high);
1426        let clusters_high = resolver_high.resolve(&[doc1.clone(), doc2.clone()]);
1427        assert_eq!(
1428            clusters_high.len(),
1429            2,
1430            "High threshold should keep separate"
1431        );
1432
1433        // Low threshold - might cluster
1434        let config_low = CDCRConfig {
1435            use_lsh: false,
1436            min_similarity: 0.2,
1437            ..Default::default()
1438        };
1439        let resolver_low = CDCRResolver::with_config(config_low);
1440        let clusters_low = resolver_low.resolve(&[doc1, doc2]);
1441        // John and Jonathan share "John" substring, so may cluster
1442        assert!(clusters_low.len() <= 2);
1443    }
1444
1445    #[test]
1446    fn test_cross_doc_cluster_methods() {
1447        let mut cluster = CrossDocCluster::new(1u64, "Test Entity");
1448        cluster.add_mention("doc1", 0);
1449        cluster.add_mention("doc2", 1);
1450        cluster.add_mention("doc1", 2); // Same doc, different mention
1451
1452        assert_eq!(cluster.len(), 3);
1453        assert_eq!(cluster.doc_count(), 2); // Only 2 unique docs
1454        assert!(!cluster.is_empty());
1455    }
1456
1457    #[test]
1458    fn test_lsh_blocker_signature() {
1459        let blocker = LSHBlocker::default();
1460
1461        // Same text should produce same signature
1462        let mentions1 = vec![
1463            MentionRef {
1464                doc_id: "d1".into(),
1465                entity_idx: 0,
1466                text: "United States of America".into(),
1467                entity_type: EntityType::Location,
1468                within_doc_cluster: None,
1469            },
1470            MentionRef {
1471                doc_id: "d2".into(),
1472                entity_idx: 0,
1473                text: "United States of America".into(),
1474                entity_type: EntityType::Location,
1475                within_doc_cluster: None,
1476            },
1477        ];
1478
1479        let candidates = blocker.candidate_pairs(&mentions1);
1480        assert!(
1481            candidates.contains(&(0, 1)),
1482            "Identical texts should be candidates"
1483        );
1484    }
1485
1486    #[test]
1487    fn test_cdcr_metrics_empty() {
1488        let metrics = CDCRMetrics::compute(&[], &[]);
1489        assert_eq!(metrics.b_cubed_f1, 0.0);
1490        assert_eq!(metrics.num_pred_clusters, 0);
1491        assert_eq!(metrics.num_gold_clusters, 0);
1492    }
1493
1494    #[test]
1495    fn test_document_builder_pattern() {
1496        let doc = Document::new("test", "Sample text").with_entities(vec![Entity::new(
1497            "Sample",
1498            EntityType::custom("Test", EntityCategory::Misc),
1499            0,
1500            6,
1501            0.9,
1502        )]);
1503
1504        assert_eq!(doc.id, "test");
1505        assert_eq!(doc.entities.len(), 1);
1506    }
1507
1508    #[test]
1509    fn test_mention_ref_equality() {
1510        let mention1 = MentionRef {
1511            doc_id: "d1".into(),
1512            entity_idx: 0,
1513            text: "Test".into(),
1514            entity_type: EntityType::Person,
1515            within_doc_cluster: Some(1),
1516        };
1517
1518        // Same doc, same index = same mention
1519        assert_eq!(mention1.doc_id, "d1");
1520        assert_eq!(mention1.entity_idx, 0);
1521    }
1522
1523    // =================================================================
1524    // Domain Dataset Tests
1525    // =================================================================
1526
1527    #[test]
1528    fn test_tech_news_dataset() {
1529        let docs = tech_news_dataset();
1530
1531        assert!(
1532            docs.len() >= 5,
1533            "Tech dataset should have at least 5 documents"
1534        );
1535
1536        // Should mention Nvidia multiple times
1537        let nvidia_mentions: usize = docs
1538            .iter()
1539            .flat_map(|d| &d.entities)
1540            .filter(|e| e.text.to_lowercase().contains("nvidia"))
1541            .count();
1542        assert!(
1543            nvidia_mentions >= 3,
1544            "Nvidia should appear in multiple documents"
1545        );
1546
1547        // Should mention Huang multiple times
1548        let huang_mentions: usize = docs
1549            .iter()
1550            .flat_map(|d| &d.entities)
1551            .filter(|e| e.text.to_lowercase().contains("huang"))
1552            .count();
1553        assert!(
1554            huang_mentions >= 3,
1555            "Huang should appear in multiple documents"
1556        );
1557    }
1558
1559    #[test]
1560    fn test_political_news_dataset() {
1561        let docs = political_news_dataset();
1562
1563        assert!(
1564            docs.len() >= 4,
1565            "Political dataset should have at least 4 documents"
1566        );
1567
1568        // Should mention Biden/Scholz multiple times
1569        let biden_mentions: usize = docs
1570            .iter()
1571            .flat_map(|d| &d.entities)
1572            .filter(|e| e.text.to_lowercase().contains("biden"))
1573            .count();
1574        assert!(
1575            biden_mentions >= 3,
1576            "Biden should appear in multiple documents"
1577        );
1578    }
1579
1580    #[test]
1581    fn test_sports_news_dataset() {
1582        let docs = sports_news_dataset();
1583
1584        assert!(
1585            docs.len() >= 4,
1586            "Sports dataset should have at least 4 documents"
1587        );
1588
1589        // Should mention Messi multiple times
1590        let messi_mentions: usize = docs
1591            .iter()
1592            .flat_map(|d| &d.entities)
1593            .filter(|e| e.text.to_lowercase().contains("messi"))
1594            .count();
1595        assert!(
1596            messi_mentions >= 4,
1597            "Messi should appear in multiple documents"
1598        );
1599    }
1600
1601    #[test]
1602    fn test_financial_news_dataset() {
1603        let docs = financial_news_dataset();
1604
1605        assert!(
1606            docs.len() >= 4,
1607            "Financial dataset should have at least 4 documents"
1608        );
1609
1610        // Should mention Apple multiple times
1611        let apple_mentions: usize = docs
1612            .iter()
1613            .flat_map(|d| &d.entities)
1614            .filter(|e| e.text.to_lowercase().contains("apple"))
1615            .count();
1616        assert!(
1617            apple_mentions >= 3,
1618            "Apple should appear in multiple documents"
1619        );
1620    }
1621
1622    #[test]
1623    fn test_science_news_dataset() {
1624        let docs = science_news_dataset();
1625
1626        assert!(
1627            docs.len() >= 4,
1628            "Science dataset should have at least 4 documents"
1629        );
1630
1631        // Should mention NASA multiple times
1632        let nasa_mentions: usize = docs
1633            .iter()
1634            .flat_map(|d| &d.entities)
1635            .filter(|e| e.text.to_lowercase().contains("nasa"))
1636            .count();
1637        assert!(
1638            nasa_mentions >= 3,
1639            "NASA should appear in multiple documents"
1640        );
1641    }
1642
1643    #[test]
1644    fn test_comprehensive_cdcr_dataset() {
1645        let docs = comprehensive_cdcr_dataset();
1646
1647        // Should combine all domain datasets
1648        let expected_min = tech_news_dataset().len()
1649            + political_news_dataset().len()
1650            + sports_news_dataset().len()
1651            + financial_news_dataset().len()
1652            + science_news_dataset().len();
1653
1654        assert_eq!(
1655            docs.len(),
1656            expected_min,
1657            "Comprehensive should combine all domain datasets"
1658        );
1659    }
1660
1661    #[test]
1662    fn test_cdcr_on_tech_news() {
1663        let docs = tech_news_dataset();
1664
1665        let config = CDCRConfig {
1666            use_lsh: false, // Brute force for reliable testing
1667            min_similarity: 0.4,
1668            ..Default::default()
1669        };
1670        let resolver = CDCRResolver::with_config(config);
1671        let clusters = resolver.resolve(&docs);
1672
1673        // Should cluster Nvidia mentions together
1674        let nvidia_cluster = clusters.iter().find(|c| {
1675            c.canonical_name.to_lowercase() == "nvidia"
1676                && c.entity_type == Some(EntityType::Organization)
1677        });
1678
1679        if let Some(nc) = nvidia_cluster {
1680            assert!(
1681                nc.doc_count() >= 2,
1682                "Nvidia should appear in at least 2 documents, found {}",
1683                nc.doc_count()
1684            );
1685        }
1686
1687        println!("Tech news CDCR clusters:");
1688        for cluster in &clusters {
1689            if cluster.doc_count() > 1 {
1690                println!(
1691                    "  {} ({:?}): {} docs",
1692                    cluster.canonical_name,
1693                    cluster.entity_type,
1694                    cluster.doc_count()
1695                );
1696            }
1697        }
1698    }
1699
1700    #[test]
1701    fn test_cdcr_on_sports_news() {
1702        let docs = sports_news_dataset();
1703
1704        let config = CDCRConfig {
1705            use_lsh: false,
1706            min_similarity: 0.4,
1707            ..Default::default()
1708        };
1709        let resolver = CDCRResolver::with_config(config);
1710        let clusters = resolver.resolve(&docs);
1711
1712        // Messi should be clustered across documents
1713        let messi_cluster = clusters
1714            .iter()
1715            .find(|c| c.canonical_name.to_lowercase().contains("messi"));
1716
1717        assert!(messi_cluster.is_some(), "Should find Messi cluster");
1718
1719        if let Some(mc) = messi_cluster {
1720            assert!(
1721                mc.doc_count() >= 3,
1722                "Messi should appear in at least 3 documents, found {}",
1723                mc.doc_count()
1724            );
1725        }
1726    }
1727
1728    #[test]
1729    fn test_cross_domain_cdcr() {
1730        // Test that cross-domain resolution doesn't create spurious links
1731        let mut docs = Vec::new();
1732
1733        // Add one doc from tech (Jordan the AI researcher)
1734        let mut tech_doc = Document::new("tech", "Jordan presented research at NeurIPS.");
1735        tech_doc.entities = vec![Entity::new("Jordan", EntityType::Person, 0, 6, 0.9)];
1736        docs.push(tech_doc);
1737
1738        // Add one doc from sports (Jordan the basketball player)
1739        // Same name but different entity - tests disambiguation by context
1740        let mut sports_doc = Document::new("sports", "Jordan scored 30 points in the game.");
1741        sports_doc.entities = vec![Entity::new("Jordan", EntityType::Person, 0, 6, 0.9)];
1742        docs.push(sports_doc);
1743
1744        let config = CDCRConfig {
1745            use_lsh: false,
1746            ..Default::default()
1747        };
1748        let resolver = CDCRResolver::with_config(config);
1749        let clusters = resolver.resolve(&docs);
1750
1751        // Note: Without entity type distinction, same-name entities will cluster
1752        // This test documents current behavior - proper disambiguation would
1753        // require additional context/features beyond simple string matching
1754        println!(
1755            "Cross-domain clusters: {:?}",
1756            clusters
1757                .iter()
1758                .map(|c| (&c.canonical_name, c.doc_count()))
1759                .collect::<Vec<_>>()
1760        );
1761    }
1762
1763    // =========================================================================
1764    // Edge Case Tests
1765    // =========================================================================
1766
1767    /// Test: Empty document set
1768    #[test]
1769    fn test_cdcr_empty_documents() {
1770        let docs: Vec<Document> = vec![];
1771        let resolver = CDCRResolver::new();
1772        let clusters = resolver.resolve(&docs);
1773
1774        assert!(
1775            clusters.is_empty(),
1776            "Empty docs should produce empty clusters"
1777        );
1778    }
1779
1780    /// Test: Single document
1781    #[test]
1782    fn test_cdcr_single_document() {
1783        let mut doc = Document::new("single", "Obama met Merkel in Berlin.");
1784        doc.entities = vec![
1785            Entity::new("Obama", EntityType::Person, 0, 5, 0.9),
1786            Entity::new("Merkel", EntityType::Person, 10, 16, 0.9),
1787            Entity::new("Berlin", EntityType::Location, 20, 26, 0.9),
1788        ];
1789
1790        let resolver = CDCRResolver::new();
1791        let clusters = resolver.resolve(&[doc]);
1792
1793        // Should create clusters for each entity
1794        assert!(!clusters.is_empty());
1795        assert!(
1796            clusters.iter().all(|c| c.doc_count() == 1),
1797            "Single doc should have doc_count=1 for all clusters"
1798        );
1799    }
1800
1801    /// Test: Documents with no entities
1802    #[test]
1803    fn test_cdcr_no_entities() {
1804        let docs = vec![
1805            Document::new("doc1", "This is some text."),
1806            Document::new("doc2", "This is more text."),
1807        ];
1808
1809        let resolver = CDCRResolver::new();
1810        let clusters = resolver.resolve(&docs);
1811
1812        assert!(
1813            clusters.is_empty(),
1814            "No entities should produce no clusters"
1815        );
1816    }
1817
1818    /// Test: Unicode entities across documents
1819    #[test]
1820    fn test_cdcr_unicode_entities() {
1821        let mut doc1 = Document::new("cn1", "習近平訪問北京。");
1822        doc1.entities = vec![
1823            Entity::new("習近平", EntityType::Person, 0, 9, 0.9),
1824            Entity::new("北京", EntityType::Location, 12, 18, 0.9),
1825        ];
1826
1827        let mut doc2 = Document::new("cn2", "習近平發表講話。");
1828        doc2.entities = vec![Entity::new("習近平", EntityType::Person, 0, 9, 0.9)];
1829
1830        let config = CDCRConfig {
1831            use_lsh: false,
1832            min_similarity: 0.5,
1833            ..Default::default()
1834        };
1835        let resolver = CDCRResolver::with_config(config);
1836        let clusters = resolver.resolve(&[doc1, doc2]);
1837
1838        // 習近平 should be clustered
1839        let xi_cluster = clusters
1840            .iter()
1841            .find(|c| c.canonical_name.contains("習近平"));
1842        assert!(xi_cluster.is_some(), "Should find Chinese name cluster");
1843        assert_eq!(xi_cluster.unwrap().doc_count(), 2);
1844    }
1845
1846    /// Test: Many documents (performance)
1847    #[test]
1848    fn test_cdcr_many_documents() {
1849        let mut docs = Vec::new();
1850
1851        for i in 0..20 {
1852            let doc_id = format!("doc{}", i);
1853            let doc_text = format!("Entity{} appears here.", i % 5);
1854            let mut doc = Document::new(&doc_id, &doc_text);
1855            doc.entities = vec![Entity::new(
1856                format!("Entity{}", i % 5),
1857                EntityType::Person,
1858                0,
1859                7,
1860                0.9,
1861            )];
1862            docs.push(doc);
1863        }
1864
1865        let config = CDCRConfig {
1866            use_lsh: true, // Use LSH for scale
1867            ..Default::default()
1868        };
1869        let resolver = CDCRResolver::with_config(config);
1870        let clusters = resolver.resolve(&docs);
1871
1872        // Should have 5 clusters (Entity0-Entity4)
1873        assert!(
1874            clusters.len() <= 5,
1875            "Should have at most 5 distinct entities"
1876        );
1877    }
1878
1879    /// Test: Entity type filtering
1880    #[test]
1881    fn test_cdcr_different_entity_types() {
1882        // Same name, different types - should not cluster together
1883        let mut doc1 = Document::new("doc1", "Apple announced new products.");
1884        doc1.entities = vec![Entity::new("Apple", EntityType::Organization, 0, 5, 0.9)];
1885
1886        let mut doc2 = Document::new("doc2", "I ate an apple today.");
1887        doc2.entities = vec![Entity::new(
1888            "apple",
1889            EntityType::custom("fruit", EntityCategory::Misc),
1890            9,
1891            14,
1892            0.9,
1893        )];
1894
1895        let config = CDCRConfig {
1896            use_lsh: false,
1897            min_similarity: 0.8,
1898            ..Default::default()
1899        };
1900        let resolver = CDCRResolver::with_config(config);
1901        let clusters = resolver.resolve(&[doc1, doc2]);
1902
1903        // With type awareness, these should be separate clusters
1904        // (Current behavior may cluster them based on name similarity)
1905        println!("Entity type clusters: {:?}", clusters.len());
1906    }
1907
1908    /// Test: Cluster metrics
1909    #[test]
1910    fn test_cdcr_cluster_metrics() {
1911        let mut doc1 = Document::new("doc1", "Obama in DC.");
1912        doc1.entities = vec![
1913            Entity::new("Obama", EntityType::Person, 0, 5, 0.9),
1914            Entity::new("DC", EntityType::Location, 9, 11, 0.8),
1915        ];
1916
1917        let mut doc2 = Document::new("doc2", "Obama spoke.");
1918        doc2.entities = vec![Entity::new("Obama", EntityType::Person, 0, 5, 0.95)];
1919
1920        let resolver = CDCRResolver::new();
1921        let clusters = resolver.resolve(&[doc1, doc2]);
1922
1923        let obama_cluster = clusters
1924            .iter()
1925            .find(|c| c.canonical_name.to_lowercase() == "obama");
1926
1927        if let Some(oc) = obama_cluster {
1928            assert_eq!(oc.doc_count(), 2);
1929            assert_eq!(oc.mention_count(), 2);
1930            // Verify cluster has mentions
1931            assert!(!oc.mentions.is_empty());
1932        }
1933    }
1934
1935    /// Test: Cross-document with within-doc chains
1936    #[test]
1937    fn test_cdcr_with_coref_chains() {
1938        let mut doc1 = Document::new("doc1", "Obama spoke. He waved.");
1939        doc1.entities = vec![
1940            Entity::new("Obama", EntityType::Person, 0, 5, 0.9),
1941            Entity::new("He", EntityType::Person, 13, 15, 0.7),
1942        ];
1943        // Simulate within-doc coref
1944        doc1.coref_chains = vec![crate::eval::coref::CorefChain::new(vec![
1945            crate::eval::coref::Mention::new("Obama", 0, 5),
1946            crate::eval::coref::Mention::new("He", 13, 15),
1947        ])];
1948
1949        let mut doc2 = Document::new("doc2", "Obama visited.");
1950        doc2.entities = vec![Entity::new("Obama", EntityType::Person, 0, 5, 0.9)];
1951
1952        let resolver = CDCRResolver::new();
1953        let clusters = resolver.resolve(&[doc1, doc2]);
1954
1955        // Obama should cluster across docs
1956        let obama_cluster = clusters
1957            .iter()
1958            .find(|c| c.canonical_name.to_lowercase() == "obama");
1959        assert!(obama_cluster.is_some());
1960    }
1961
1962    /// Test: Canonical name selection
1963    #[test]
1964    fn test_cdcr_canonical_name_selection() {
1965        // Full name should be preferred over abbreviated
1966        let mut doc1 = Document::new("doc1", "Barack Obama spoke.");
1967        doc1.entities = vec![Entity::new("Barack Obama", EntityType::Person, 0, 12, 0.95)];
1968
1969        let mut doc2 = Document::new("doc2", "Obama visited.");
1970        doc2.entities = vec![Entity::new("Obama", EntityType::Person, 0, 5, 0.9)];
1971
1972        let mut doc3 = Document::new("doc3", "President Obama arrived.");
1973        doc3.entities = vec![Entity::new(
1974            "President Obama",
1975            EntityType::Person,
1976            0,
1977            15,
1978            0.92,
1979        )];
1980
1981        let config = CDCRConfig {
1982            use_lsh: false,
1983            min_similarity: 0.3, // Low threshold to ensure clustering
1984            ..Default::default()
1985        };
1986        let resolver = CDCRResolver::with_config(config);
1987        let clusters = resolver.resolve(&[doc1, doc2, doc3]);
1988
1989        // Should have some Obama-related cluster
1990        let has_obama = clusters
1991            .iter()
1992            .any(|c| c.canonical_name.to_lowercase().contains("obama"));
1993        assert!(has_obama, "Should find Obama cluster");
1994    }
1995}