1use crate::eval::coref::CorefChain;
57use anno::{Entity, EntityCategory, EntityType};
58use serde::{Deserialize, Serialize};
59use std::collections::{HashMap, HashSet};
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct Document {
68 pub id: String,
70 pub text: String,
72 pub entities: Vec<Entity>,
74 pub coref_chains: Vec<CorefChain>,
76}
77
78impl Document {
79 #[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 #[must_use]
92 pub fn with_entities(mut self, entities: Vec<Entity>) -> Self {
93 self.entities = entities;
94 self
95 }
96
97 #[must_use]
99 pub fn with_coref(mut self, chains: Vec<CorefChain>) -> Self {
100 self.coref_chains = chains;
101 self
102 }
103
104 #[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#[derive(Debug, Clone)]
125pub struct MentionRef {
126 pub doc_id: String,
128 pub entity_idx: usize,
130 pub text: String,
132 pub entity_type: EntityType,
134 pub within_doc_cluster: Option<u64>,
136}
137
138#[derive(Debug, Clone, Default, Serialize, Deserialize)]
143pub struct CrossDocCluster {
144 pub id: u64,
146 pub canonical_name: String,
148 pub entity_type: Option<EntityType>,
150 pub documents: Vec<String>,
152 pub mentions: Vec<(String, usize)>,
154 pub kb_id: Option<String>,
156 pub confidence: f64,
158}
159
160impl CrossDocCluster {
161 #[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 #[must_use]
177 pub fn len(&self) -> usize {
178 self.mentions.len()
179 }
180
181 #[must_use]
183 pub fn mention_count(&self) -> usize {
184 self.len()
185 }
186
187 #[must_use]
189 pub fn is_empty(&self) -> bool {
190 self.mentions.is_empty()
191 }
192
193 #[must_use]
195 pub fn doc_count(&self) -> usize {
196 self.documents.iter().collect::<HashSet<_>>().len()
197 }
198
199 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 #[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
215impl 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, }
257 }
258}
259
260#[derive(Debug, Clone)]
278pub struct LSHBlocker {
279 pub num_bands: usize,
281 pub rows_per_band: usize,
283 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 #[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 #[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 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 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 fn compute_signature(&self, text: &str) -> Vec<u64> {
350 let normalized = text.to_lowercase();
351 let ngrams = self.extract_ngrams(&normalized);
352
353 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 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 fn hash_ngram(&self, ngram: &str, seed: u64) -> u64 {
384 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 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 #[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#[derive(Clone)]
421pub struct CDCRConfig {
422 pub min_similarity: f64,
424 pub use_lsh: bool,
426 pub lsh: LSHBlocker,
428 pub require_type_match: bool,
430 #[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#[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 #[must_use]
515 pub fn new() -> Self {
516 Self::default()
517 }
518
519 #[must_use]
521 pub fn with_config(config: CDCRConfig) -> Self {
522 Self { config }
523 }
524
525 #[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 #[must_use]
542 pub fn resolve(&self, documents: &[Document]) -> Vec<CrossDocCluster> {
543 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 let candidates = if self.config.use_lsh {
552 self.config.lsh.candidate_pairs(&mentions)
553 } else {
554 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 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 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 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 fn should_cluster(&self, a: &MentionRef, b: &MentionRef) -> bool {
602 if self.config.require_type_match && a.entity_type != b.entity_type {
604 return false;
605 }
606
607 let sim = self.mention_similarity(a, b);
609 sim >= self.config.min_similarity
610 }
611
612 fn mention_similarity(&self, a: &MentionRef, b: &MentionRef) -> f64 {
617 #[cfg(feature = "eval")]
618 if let Some(ref encoder) = self.config.cluster_encoder {
619 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 let emb_a = encoder.encode_cluster(&cluster_a, None);
647 let emb_b = encoder.encode_cluster(&cluster_b, None);
648
649 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 anno::similarity::string_similarity(&a.text, &b.text)
668 }
669
670 fn find(parent: &mut [usize], mut i: usize) -> usize {
672 let mut root = i;
674 while parent[root] != root {
675 root = parent[root];
676 }
677 while parent[i] != root {
679 let next = parent[i];
680 parent[i] = root;
681 i = next;
682 }
683 root
684 }
685
686 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#[derive(Debug, Clone, Default)]
702pub struct CDCRMetrics {
703 pub b_cubed_precision: f64,
705 pub b_cubed_recall: f64,
707 pub b_cubed_f1: f64,
709 pub num_pred_clusters: usize,
711 pub num_gold_clusters: usize,
713}
714
715impl CDCRMetrics {
716 #[must_use]
722 pub fn compute(predicted: &[CrossDocCluster], gold: &[CrossDocCluster]) -> Self {
723 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 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 }
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 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#[must_use]
799pub fn tech_news_dataset() -> Vec<Document> {
800 let mut docs = Vec::new();
801
802 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 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 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 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 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#[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#[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#[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#[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#[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#[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 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(), 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 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 let config = CDCRConfig {
1246 use_lsh: false, ..Default::default()
1248 };
1249 let resolver = CDCRResolver::with_config(config);
1250
1251 let clusters = resolver.resolve(&docs);
1252
1253 assert!(!clusters.is_empty(), "Should produce clusters");
1255
1256 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 let config = CDCRConfig {
1291 min_similarity: 0.3, use_lsh: false, ..Default::default()
1294 };
1295 let resolver = CDCRResolver::with_config(config);
1296 let clusters = resolver.resolve(&[doc1, doc2]);
1297
1298 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 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 #[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 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, ..Default::default()
1403 };
1404 let resolver = CDCRResolver::with_config(config);
1405 let clusters = resolver.resolve(&[doc1, doc2]);
1406
1407 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 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 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 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); assert_eq!(cluster.len(), 3);
1453 assert_eq!(cluster.doc_count(), 2); assert!(!cluster.is_empty());
1455 }
1456
1457 #[test]
1458 fn test_lsh_blocker_signature() {
1459 let blocker = LSHBlocker::default();
1460
1461 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 assert_eq!(mention1.doc_id, "d1");
1520 assert_eq!(mention1.entity_idx, 0);
1521 }
1522
1523 #[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 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 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 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 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 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 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 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, min_similarity: 0.4,
1668 ..Default::default()
1669 };
1670 let resolver = CDCRResolver::with_config(config);
1671 let clusters = resolver.resolve(&docs);
1672
1673 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 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 let mut docs = Vec::new();
1732
1733 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 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 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 #[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]
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 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]
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]
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 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]
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, ..Default::default()
1868 };
1869 let resolver = CDCRResolver::with_config(config);
1870 let clusters = resolver.resolve(&docs);
1871
1872 assert!(
1874 clusters.len() <= 5,
1875 "Should have at most 5 distinct entities"
1876 );
1877 }
1878
1879 #[test]
1881 fn test_cdcr_different_entity_types() {
1882 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 println!("Entity type clusters: {:?}", clusters.len());
1906 }
1907
1908 #[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 assert!(!oc.mentions.is_empty());
1932 }
1933 }
1934
1935 #[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 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 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]
1964 fn test_cdcr_canonical_name_selection() {
1965 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, ..Default::default()
1985 };
1986 let resolver = CDCRResolver::with_config(config);
1987 let clusters = resolver.resolve(&[doc1, doc2, doc3]);
1988
1989 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}