Skip to main content

anno_eval/eval/
neural_cluster_encoder.rs

1//! Neural Cluster Encoder for Cross-Context Coreference.
2//!
3//! Implements the cluster encoding approach from xCoRe (Martinelli et al., 2025)
4//! using anno's Candle infrastructure.
5//!
6//! # Architecture
7//!
8//! ```text
9//! ┌─────────────────────────────────────────────────────────────────────┐
10//! │                    Neural Cluster Encoder                           │
11//! ├─────────────────────────────────────────────────────────────────────┤
12//! │ 1. Encode each mention with TextEncoder (DeBERTa/ModernBERT)       │
13//! │    └── m_i = Encoder(text_i) → [seq_len, hidden_dim]              │
14//! │                                                                     │
15//! │ 2. Extract mention representations via mean pooling                │
16//! │    └── h_i = mean(m_i[start:end])                                  │
17//! │                                                                     │
18//! │ 3. Pool mentions with single-layer Transformer (xCoRe style)       │
19//! │    └── hs(W_j) = TransformerLayer([h_1, ..., h_k])                 │
20//! │                                                                     │
21//! │ 4. Output cluster embedding for merge scoring                       │
22//! │    └── cluster_emb = mean(hs(W_j)) or [CLS] pooling               │
23//! └─────────────────────────────────────────────────────────────────────┘
24//! ```
25//!
26//! # References
27//!
28//! - Martinelli et al. (2025): "xCoRe: Cross-context Coreference Resolution"
29//! - Section 3.2.2: Cross-context Cluster Merging
30
31use crate::eval::cluster_encoder::{
32    ClusterEmbedding, ClusterEncoder, ClusterMention, LocalCluster, MergeScorer,
33};
34use std::collections::HashMap;
35
36#[cfg(feature = "candle")]
37use {
38    anno::backends::encoder_candle::CandleTextEncoder,
39    candle_core::{DType, Device, Module, Tensor, D},
40    candle_nn::{layer_norm, linear, LayerNorm, Linear, VarBuilder},
41};
42
43// =============================================================================
44// Configuration
45// =============================================================================
46
47/// Configuration for neural cluster encoder.
48#[derive(Debug, Clone)]
49pub struct NeuralClusterConfig {
50    /// Hidden dimension (should match TextEncoder)
51    pub hidden_dim: usize,
52    /// Number of attention heads for pooling transformer
53    pub num_heads: usize,
54    /// Dropout probability
55    pub dropout: f32,
56    /// Use learned \[CLS\] token for pooling
57    pub use_cls_pooling: bool,
58    /// Maximum mentions per cluster (truncate if exceeded)
59    pub max_mentions: usize,
60    /// Merge probability threshold
61    pub merge_threshold: f32,
62}
63
64impl Default for NeuralClusterConfig {
65    fn default() -> Self {
66        Self {
67            hidden_dim: 768, // DeBERTa-base default
68            num_heads: 12,
69            dropout: 0.1,
70            use_cls_pooling: false, // xCoRe uses mean pooling
71            max_mentions: 50,
72            merge_threshold: 0.5,
73        }
74    }
75}
76
77// =============================================================================
78// Neural Cluster Encoder (Candle)
79// =============================================================================
80
81#[cfg(feature = "candle")]
82/// Neural cluster encoder using Candle.
83///
84/// Encodes clusters using:
85/// 1. Pre-trained text encoder for mention representations
86/// 2. Single-layer Transformer for cluster pooling (xCoRe style)
87pub struct CandleClusterEncoder<E: CandleTextEncoder> {
88    /// Text encoder (DeBERTa, ModernBERT, etc.)
89    encoder: E,
90    /// Pooling transformer layer
91    pooling_layer: ClusterPoolingLayer,
92    /// Configuration
93    config: NeuralClusterConfig,
94    /// Device
95    device: Device,
96}
97
98#[cfg(feature = "candle")]
99impl<E: CandleTextEncoder> CandleClusterEncoder<E> {
100    /// Create a new neural cluster encoder.
101    pub fn new(encoder: E, config: NeuralClusterConfig) -> crate::Result<Self> {
102        let device = Device::cuda_if_available(0).unwrap_or(Device::Cpu);
103        let pooling_layer = ClusterPoolingLayer::new(&config, &device)?;
104
105        Ok(Self {
106            encoder,
107            pooling_layer,
108            config,
109            device,
110        })
111    }
112
113    /// Encode a cluster's mentions into a single embedding.
114    fn encode_cluster_impl(&self, cluster: &LocalCluster) -> crate::Result<Vec<f32>> {
115        if cluster.mentions.is_empty() {
116            return Ok(vec![0.0; self.config.hidden_dim]);
117        }
118
119        // Truncate to max mentions
120        let mentions: Vec<&ClusterMention> = cluster
121            .mentions
122            .iter()
123            .take(self.config.max_mentions)
124            .collect();
125
126        // Encode each mention text
127        let mut mention_embeddings = Vec::new();
128        for mention in &mentions {
129            let (embeddings, seq_len) = self.encoder.encode(&mention.text)?;
130
131            // Mean pool over tokens
132            let hidden_dim = self.config.hidden_dim;
133            let mut pooled = vec![0.0f32; hidden_dim];
134            if seq_len > 0 {
135                for i in 0..seq_len {
136                    for j in 0..hidden_dim {
137                        pooled[j] += embeddings[i * hidden_dim + j];
138                    }
139                }
140                for p in &mut pooled {
141                    *p /= seq_len as f32;
142                }
143            }
144            mention_embeddings.push(pooled);
145        }
146
147        // Stack into tensor [num_mentions, hidden_dim]
148        let num_mentions = mention_embeddings.len();
149        let flat: Vec<f32> = mention_embeddings.into_iter().flatten().collect();
150        let tensor = Tensor::from_vec(flat, (num_mentions, self.config.hidden_dim), &self.device)
151            .map_err(|e: candle_core::Error| crate::Error::Inference(e.to_string()))?;
152
153        // Apply pooling transformer
154        let pooled = self.pooling_layer.forward(&tensor)?;
155
156        // Extract as Vec<f32>
157        let result = pooled
158            .to_vec1::<f32>()
159            .map_err(|e: candle_core::Error| crate::Error::Inference(e.to_string()))?;
160
161        Ok(result)
162    }
163}
164
165#[cfg(feature = "candle")]
166impl<E: CandleTextEncoder> ClusterEncoder for CandleClusterEncoder<E> {
167    fn encode_cluster(
168        &self,
169        cluster: &LocalCluster,
170        _hidden_states: Option<&[Vec<f32>]>,
171    ) -> ClusterEmbedding {
172        let embedding = self
173            .encode_cluster_impl(cluster)
174            .unwrap_or_else(|_| vec![0.0; self.config.hidden_dim]);
175
176        ClusterEmbedding {
177            cluster_id: cluster.id,
178            context_id: cluster.context_id,
179            embedding,
180            mention_count: cluster.mentions.len(),
181        }
182    }
183
184    fn embedding_dim(&self) -> usize {
185        self.config.hidden_dim
186    }
187}
188
189// =============================================================================
190// Cluster Pooling Layer
191// =============================================================================
192
193#[cfg(feature = "candle")]
194/// Single-layer Transformer for pooling mention representations.
195///
196/// From xCoRe (Section 3.2.2):
197/// "We compute the representation for each cluster W_j using a single-layer
198/// Transformer T to encode the hidden states of each of its mentions."
199struct ClusterPoolingLayer {
200    /// Query projection
201    wq: Linear,
202    /// Key projection
203    wk: Linear,
204    /// Value projection
205    wv: Linear,
206    /// Output projection
207    wo: Linear,
208    /// Layer norm
209    ln: LayerNorm,
210    /// Number of heads
211    num_heads: usize,
212    /// Head dimension
213    head_dim: usize,
214}
215
216#[cfg(feature = "candle")]
217impl ClusterPoolingLayer {
218    fn new(config: &NeuralClusterConfig, device: &Device) -> crate::Result<Self> {
219        let varmap = candle_nn::VarMap::new();
220        let vb = VarBuilder::from_varmap(&varmap, DType::F32, device);
221
222        let hidden_dim = config.hidden_dim;
223        let num_heads = config.num_heads;
224        let head_dim = hidden_dim / num_heads;
225
226        let wq = linear(hidden_dim, hidden_dim, vb.pp("wq"))
227            .map_err(|e| crate::Error::Inference(format!("Linear wq: {}", e)))?;
228        let wk = linear(hidden_dim, hidden_dim, vb.pp("wk"))
229            .map_err(|e| crate::Error::Inference(format!("Linear wk: {}", e)))?;
230        let wv = linear(hidden_dim, hidden_dim, vb.pp("wv"))
231            .map_err(|e| crate::Error::Inference(format!("Linear wv: {}", e)))?;
232        let wo = linear(hidden_dim, hidden_dim, vb.pp("wo"))
233            .map_err(|e| crate::Error::Inference(format!("Linear wo: {}", e)))?;
234        let ln = layer_norm(hidden_dim, 1e-5, vb.pp("ln"))
235            .map_err(|e| crate::Error::Inference(format!("LayerNorm: {}", e)))?;
236
237        Ok(Self {
238            wq,
239            wk,
240            wv,
241            wo,
242            ln,
243            num_heads,
244            head_dim,
245        })
246    }
247
248    /// Forward pass: pool mentions into single cluster representation.
249    fn forward(&self, x: &Tensor) -> crate::Result<Tensor> {
250        let (seq_len, hidden_dim) = x
251            .dims2()
252            .map_err(|e| crate::Error::Inference(format!("Dims: {}", e)))?;
253
254        // Self-attention
255        let q = self
256            .wq
257            .forward(x)
258            .map_err(|e| crate::Error::Inference(format!("Q: {}", e)))?;
259        let k = self
260            .wk
261            .forward(x)
262            .map_err(|e| crate::Error::Inference(format!("K: {}", e)))?;
263        let v = self
264            .wv
265            .forward(x)
266            .map_err(|e| crate::Error::Inference(format!("V: {}", e)))?;
267
268        // Reshape for multi-head attention: [seq, heads, head_dim]
269        let q = q
270            .reshape((seq_len, self.num_heads, self.head_dim))
271            .map_err(|e| crate::Error::Inference(format!("Q reshape: {}", e)))?
272            .transpose(0, 1)
273            .map_err(|e| crate::Error::Inference(format!("Q transpose: {}", e)))?;
274        let k = k
275            .reshape((seq_len, self.num_heads, self.head_dim))
276            .map_err(|e| crate::Error::Inference(format!("K reshape: {}", e)))?
277            .transpose(0, 1)
278            .map_err(|e| crate::Error::Inference(format!("K transpose: {}", e)))?;
279        let v = v
280            .reshape((seq_len, self.num_heads, self.head_dim))
281            .map_err(|e| crate::Error::Inference(format!("V reshape: {}", e)))?
282            .transpose(0, 1)
283            .map_err(|e| crate::Error::Inference(format!("V transpose: {}", e)))?;
284
285        // Attention scores
286        let scale = (self.head_dim as f64).sqrt();
287        let scores = q
288            .matmul(
289                &k.transpose(1, 2)
290                    .map_err(|e| crate::Error::Inference(format!("K^T: {}", e)))?,
291            )
292            .map_err(|e| crate::Error::Inference(format!("QK^T: {}", e)))?
293            .affine(1.0 / scale, 0.0)
294            .map_err(|e| crate::Error::Inference(format!("Scale: {}", e)))?;
295
296        let attn = candle_nn::ops::softmax(&scores, D::Minus1)
297            .map_err(|e| crate::Error::Inference(format!("Softmax: {}", e)))?;
298
299        // Apply attention to values
300        let context = attn
301            .matmul(&v)
302            .map_err(|e| crate::Error::Inference(format!("Attn*V: {}", e)))?;
303
304        // Reshape back: [heads, seq, head_dim] -> [seq, hidden]
305        let context = context
306            .transpose(0, 1)
307            .map_err(|e| crate::Error::Inference(format!("Context transpose: {}", e)))?
308            .reshape((seq_len, hidden_dim))
309            .map_err(|e| crate::Error::Inference(format!("Context reshape: {}", e)))?;
310
311        // Output projection + residual + layer norm
312        let out = self
313            .wo
314            .forward(&context)
315            .map_err(|e| crate::Error::Inference(format!("Wo: {}", e)))?;
316        let out = (x + &out).map_err(|e| crate::Error::Inference(format!("Residual: {}", e)))?;
317        let out = self
318            .ln
319            .forward(&out)
320            .map_err(|e| crate::Error::Inference(format!("LayerNorm: {}", e)))?;
321
322        // Mean pool over mentions to get single cluster representation
323        out.mean(0)
324            .map_err(|e| crate::Error::Inference(format!("Mean pool: {}", e)))
325    }
326}
327
328// =============================================================================
329// Neural Merge Scorer
330// =============================================================================
331
332#[cfg(feature = "candle")]
333/// Neural merge scorer using learned bilinear scoring.
334///
335/// From xCoRe (Section 3.2.2):
336/// "We calculate the pairwise coreference probability p_cm between clusters'
337/// hidden representations using a linear classification layer."
338pub struct NeuralMergeScorer {
339    /// Bilinear weight matrix
340    bilinear: Linear,
341    /// Output classification layer
342    classifier: Linear,
343    /// Device
344    device: Device,
345}
346
347#[cfg(feature = "candle")]
348impl NeuralMergeScorer {
349    /// Create a new neural merge scorer.
350    pub fn new(hidden_dim: usize) -> crate::Result<Self> {
351        let device = Device::cuda_if_available(0).unwrap_or(Device::Cpu);
352        let varmap = candle_nn::VarMap::new();
353        let vb = VarBuilder::from_varmap(&varmap, DType::F32, &device);
354
355        // Concatenate two cluster embeddings -> hidden_dim * 2
356        let bilinear = linear(hidden_dim * 2, hidden_dim, vb.pp("bilinear"))
357            .map_err(|e| crate::Error::Inference(format!("Bilinear: {}", e)))?;
358        let classifier = linear(hidden_dim, 1, vb.pp("classifier"))
359            .map_err(|e| crate::Error::Inference(format!("Classifier: {}", e)))?;
360
361        Ok(Self {
362            bilinear,
363            classifier,
364            device,
365        })
366    }
367
368    /// Score a pair of cluster embeddings.
369    fn score_impl(&self, emb_a: &[f32], emb_b: &[f32]) -> crate::Result<f32> {
370        // Concatenate embeddings
371        let concat: Vec<f32> = emb_a.iter().chain(emb_b.iter()).cloned().collect();
372        let input = Tensor::from_vec(concat, (1, emb_a.len() + emb_b.len()), &self.device)
373            .map_err(|e| crate::Error::Inference(format!("Input tensor: {}", e)))?;
374
375        // Forward through network
376        let hidden = self
377            .bilinear
378            .forward(&input)
379            .map_err(|e| crate::Error::Inference(format!("Bilinear forward: {}", e)))?;
380        let hidden = hidden
381            .relu()
382            .map_err(|e| crate::Error::Inference(format!("ReLU: {}", e)))?;
383        let logit = self
384            .classifier
385            .forward(&hidden)
386            .map_err(|e| crate::Error::Inference(format!("Classifier forward: {}", e)))?;
387
388        // Sigmoid to get probability
389        let prob = candle_nn::ops::sigmoid(&logit)
390            .map_err(|e| crate::Error::Inference(format!("Sigmoid: {}", e)))?;
391
392        let score = prob
393            .to_vec2::<f32>()
394            .map_err(|e| crate::Error::Inference(format!("To vec: {}", e)))?[0][0];
395
396        Ok(score)
397    }
398}
399
400#[cfg(feature = "candle")]
401impl MergeScorer for NeuralMergeScorer {
402    fn score(&self, embedding_a: &ClusterEmbedding, embedding_b: &ClusterEmbedding) -> f32 {
403        self.score_impl(&embedding_a.embedding, &embedding_b.embedding)
404            .unwrap_or(0.0)
405    }
406}
407
408// =============================================================================
409// CDCR Integration Adapter
410// =============================================================================
411
412/// Adapter to use ClusterEncoder with existing CDCR infrastructure.
413///
414/// Converts between `cdcr::Document` and `cluster_encoder::LocalCluster`.
415pub struct CDCRAdapter;
416
417impl CDCRAdapter {
418    /// Convert CDCR documents to local clusters.
419    pub fn documents_to_clusters(
420        docs: &[crate::eval::cdcr::Document],
421    ) -> HashMap<usize, Vec<LocalCluster>> {
422        let mut all_clusters = HashMap::new();
423
424        for (doc_idx, doc) in docs.iter().enumerate() {
425            let mut clusters = Vec::new();
426
427            // Each coref chain becomes a cluster
428            for (chain_idx, chain) in doc.coref_chains.iter().enumerate() {
429                let mut cluster = LocalCluster::new(chain_idx, doc_idx);
430
431                for mention in &chain.mentions {
432                    cluster.add_mention(ClusterMention {
433                        start: mention.start,
434                        end: mention.end,
435                        text: mention.text.clone(),
436                        context_id: doc_idx,
437                    });
438                }
439
440                cluster.compute_canonical();
441                clusters.push(cluster);
442            }
443
444            // Also add singletons (entities not in chains)
445            let chained_starts: std::collections::HashSet<usize> = doc
446                .coref_chains
447                .iter()
448                .flat_map(|c| c.mentions.iter().map(|m| m.start))
449                .collect();
450
451            for entity in &doc.entities {
452                if !chained_starts.contains(&entity.start()) {
453                    let mut cluster = LocalCluster::new(clusters.len(), doc_idx);
454                    cluster.add_mention(ClusterMention {
455                        start: entity.start(),
456                        end: entity.end(),
457                        text: entity.text.clone(),
458                        context_id: doc_idx,
459                    });
460                    cluster.compute_canonical();
461                    clusters.push(cluster);
462                }
463            }
464
465            all_clusters.insert(doc_idx, clusters);
466        }
467
468        all_clusters
469    }
470
471    /// Convert merged clusters back to CrossDocClusters.
472    pub fn clusters_to_crossdoc(
473        merged: &[crate::eval::cluster_encoder::MergedCluster],
474        docs: &[crate::eval::cdcr::Document],
475    ) -> Vec<crate::eval::cdcr::CrossDocCluster> {
476        merged
477            .iter()
478            .map(|m| {
479                let mut cluster = crate::eval::cdcr::CrossDocCluster::new(
480                    m.id as u64,
481                    m.canonical.as_deref().unwrap_or(""),
482                );
483
484                for mention in &m.mentions {
485                    if let Some(doc) = docs.get(mention.context_id) {
486                        // Find entity index in document
487                        let entity_idx = doc
488                            .entities
489                            .iter()
490                            .position(|e| e.start() == mention.start && e.end() == mention.end)
491                            .unwrap_or(0);
492                        cluster.add_mention(&doc.id, entity_idx);
493                    }
494                }
495
496                cluster
497            })
498            .collect()
499    }
500}
501
502// =============================================================================
503// Unified Cross-Context Resolver
504// =============================================================================
505
506/// Unified resolver for cross-context coreference using xCoRe approach.
507///
508/// Supports both:
509/// - Long document: Split into windows → resolve across windows
510/// - Cross-document: Multiple docs → resolve across documents
511pub struct UnifiedCrossContextResolver<E: ClusterEncoder, S: MergeScorer> {
512    encoder: E,
513    scorer: S,
514    config: CrossContextConfig,
515}
516
517/// Configuration for cross-context resolution.
518#[derive(Debug, Clone)]
519pub struct CrossContextConfig {
520    /// Window size for long documents
521    pub window_size: usize,
522    /// Window overlap
523    pub window_overlap: usize,
524    /// Merge probability threshold
525    pub merge_threshold: f32,
526}
527
528impl Default for CrossContextConfig {
529    fn default() -> Self {
530        Self {
531            window_size: 4000,
532            window_overlap: 256,
533            merge_threshold: 0.5,
534        }
535    }
536}
537
538impl<E: ClusterEncoder, S: MergeScorer> UnifiedCrossContextResolver<E, S> {
539    /// Create a new resolver.
540    pub fn new(encoder: E, scorer: S, config: CrossContextConfig) -> Self {
541        Self {
542            encoder,
543            scorer,
544            config,
545        }
546    }
547
548    /// Resolve coreference across CDCR documents.
549    pub fn resolve_documents(
550        &self,
551        docs: &[crate::eval::cdcr::Document],
552    ) -> Vec<crate::eval::cdcr::CrossDocCluster> {
553        // Convert to local clusters
554        let local_clusters = CDCRAdapter::documents_to_clusters(docs);
555
556        // Encode and merge
557        let merged = self.merge_clusters(&local_clusters);
558
559        // Convert back to CrossDocCluster format
560        CDCRAdapter::clusters_to_crossdoc(&merged, docs)
561    }
562
563    /// Internal merge implementation.
564    fn merge_clusters(
565        &self,
566        local_clusters: &HashMap<usize, Vec<LocalCluster>>,
567    ) -> Vec<crate::eval::cluster_encoder::MergedCluster> {
568        // Encode all clusters
569        let mut embeddings: Vec<ClusterEmbedding> = Vec::new();
570        for clusters in local_clusters.values() {
571            for cluster in clusters {
572                let emb = self.encoder.encode_cluster(cluster, None);
573                embeddings.push(emb);
574            }
575        }
576
577        if embeddings.is_empty() {
578            return Vec::new();
579        }
580
581        // Score pairwise merges (skip same-context)
582        let mut merge_pairs: Vec<(usize, usize, f32)> = Vec::new();
583        for (i, emb_a) in embeddings.iter().enumerate() {
584            for (j, emb_b) in embeddings.iter().enumerate().skip(i + 1) {
585                if emb_a.context_id == emb_b.context_id {
586                    continue;
587                }
588
589                let score = self.scorer.score(emb_a, emb_b);
590                if score >= self.config.merge_threshold {
591                    merge_pairs.push((i, j, score));
592                }
593            }
594        }
595
596        // Sort by score descending
597        merge_pairs.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
598
599        // Union-Find merge
600        let n = embeddings.len();
601        let mut parent: Vec<usize> = (0..n).collect();
602        let mut rank: Vec<usize> = vec![0; n];
603
604        fn find(parent: &mut [usize], i: usize) -> usize {
605            if parent[i] != i {
606                parent[i] = find(parent, parent[i]);
607            }
608            parent[i]
609        }
610
611        fn union(parent: &mut [usize], rank: &mut [usize], x: usize, y: usize) {
612            let px = find(parent, x);
613            let py = find(parent, y);
614            if px == py {
615                return;
616            }
617            match rank[px].cmp(&rank[py]) {
618                std::cmp::Ordering::Less => parent[px] = py,
619                std::cmp::Ordering::Greater => parent[py] = px,
620                std::cmp::Ordering::Equal => {
621                    parent[py] = px;
622                    rank[px] += 1;
623                }
624            }
625        }
626
627        for (i, j, _) in merge_pairs {
628            union(&mut parent, &mut rank, i, j);
629        }
630
631        // Build merged clusters
632        let mut cluster_map: HashMap<usize, Vec<usize>> = HashMap::new();
633        for i in 0..n {
634            let root = find(&mut parent, i);
635            cluster_map.entry(root).or_default().push(i);
636        }
637
638        cluster_map
639            .into_iter()
640            .enumerate()
641            .map(|(merged_id, (_root, indices))| {
642                let mut merged = crate::eval::cluster_encoder::MergedCluster {
643                    id: merged_id,
644                    source_clusters: Vec::new(),
645                    mentions: Vec::new(),
646                    canonical: None,
647                };
648
649                for idx in indices {
650                    let emb = &embeddings[idx];
651                    merged
652                        .source_clusters
653                        .push((emb.context_id, emb.cluster_id));
654
655                    // Find original cluster and copy mentions
656                    if let Some(clusters) = local_clusters.get(&emb.context_id) {
657                        if let Some(cluster) = clusters.iter().find(|c| c.id == emb.cluster_id) {
658                            merged.mentions.extend(cluster.mentions.clone());
659                            if merged.canonical.is_none() {
660                                merged.canonical = cluster.canonical.clone();
661                            }
662                        }
663                    }
664                }
665
666                merged
667            })
668            .collect()
669    }
670}
671
672// =============================================================================
673// Incremental Coref Integration
674// =============================================================================
675
676/// Adapter to integrate IncrementalCorefResolver with cross-context system.
677///
678/// Connects the long-document incremental resolver with xCoRe's
679/// cross-context cluster merging.
680pub struct IncrementalCorefAdapter;
681
682impl IncrementalCorefAdapter {
683    /// Convert incremental resolver output to local clusters for merging.
684    ///
685    /// Each window is treated as a separate context.
686    pub fn windows_to_clusters(windows: &[WindowOutput]) -> HashMap<usize, Vec<LocalCluster>> {
687        let mut all_clusters = HashMap::new();
688
689        for (window_idx, output) in windows.iter().enumerate() {
690            let mut clusters = Vec::new();
691
692            for (chain_idx, chain) in output.chains.iter().enumerate() {
693                let mut cluster = LocalCluster::new(chain_idx, window_idx);
694
695                for mention in &chain.mentions {
696                    cluster.add_mention(ClusterMention {
697                        start: mention.start,
698                        end: mention.end,
699                        text: mention.text.clone(),
700                        context_id: window_idx,
701                    });
702                }
703
704                cluster.compute_canonical();
705                clusters.push(cluster);
706            }
707
708            all_clusters.insert(window_idx, clusters);
709        }
710
711        all_clusters
712    }
713
714    /// Build final chains from merged clusters.
715    pub fn clusters_to_chains(
716        merged: &[crate::eval::cluster_encoder::MergedCluster],
717    ) -> Vec<crate::eval::coref::CorefChain> {
718        use crate::eval::coref::{CorefChain, Mention, MentionType};
719
720        merged
721            .iter()
722            .map(|m| {
723                let mentions: Vec<Mention> = m
724                    .mentions
725                    .iter()
726                    .map(|cm| Mention {
727                        text: cm.text.clone(),
728                        start: cm.start,
729                        end: cm.end,
730                        head_start: None,
731                        head_end: None,
732                        entity_type: None,
733                        mention_type: Some(MentionType::Proper),
734                    })
735                    .collect();
736
737                CorefChain::new(mentions)
738            })
739            .collect()
740    }
741}
742
743/// Output from a single window of incremental processing.
744#[derive(Debug, Clone)]
745pub struct WindowOutput {
746    /// Window index
747    pub window_idx: usize,
748    /// Character offset of window start
749    pub start_offset: usize,
750    /// Character offset of window end
751    pub end_offset: usize,
752    /// Chains extracted from this window
753    pub chains: Vec<crate::eval::coref::CorefChain>,
754}
755
756impl WindowOutput {
757    /// Create a new window output.
758    pub fn new(
759        window_idx: usize,
760        start_offset: usize,
761        end_offset: usize,
762        chains: Vec<crate::eval::coref::CorefChain>,
763    ) -> Self {
764        Self {
765            window_idx,
766            start_offset,
767            end_offset,
768            chains,
769        }
770    }
771}
772
773/// Extended cross-context resolver that works with long documents.
774impl<E: ClusterEncoder, S: MergeScorer> UnifiedCrossContextResolver<E, S> {
775    /// Resolve coreference in a long document by splitting into windows.
776    ///
777    /// Uses the xCoRe approach:
778    /// 1. Split document into overlapping windows
779    /// 2. Extract clusters within each window (using incremental resolver)
780    /// 3. Merge clusters across windows
781    pub fn resolve_long_document_windows(
782        &self,
783        windows: &[WindowOutput],
784    ) -> Vec<crate::eval::coref::CorefChain> {
785        // Convert windows to local clusters
786        let local_clusters = IncrementalCorefAdapter::windows_to_clusters(windows);
787
788        // Merge across contexts
789        let merged = self.merge_clusters(&local_clusters);
790
791        // Convert back to chains
792        IncrementalCorefAdapter::clusters_to_chains(&merged)
793    }
794
795    /// Get the configuration.
796    pub fn config(&self) -> &CrossContextConfig {
797        &self.config
798    }
799}
800
801// =============================================================================
802// Tests
803// =============================================================================
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808    use crate::eval::cluster_encoder::{CosineMergeScorer, HeuristicClusterEncoder};
809
810    #[test]
811    fn test_cdcr_adapter_empty() {
812        let docs: Vec<crate::eval::cdcr::Document> = vec![];
813        let clusters = CDCRAdapter::documents_to_clusters(&docs);
814        assert!(clusters.is_empty());
815    }
816
817    #[test]
818    fn test_cdcr_adapter_single_doc() {
819        use crate::eval::cdcr::Document;
820        use anno::{Entity, EntityType};
821
822        let doc = Document::new("doc1", "Obama visited France.").with_entities(vec![Entity::new(
823            "Obama",
824            EntityType::Person,
825            0,
826            5,
827            0.9,
828        )]);
829
830        let clusters = CDCRAdapter::documents_to_clusters(&[doc]);
831        assert_eq!(clusters.len(), 1);
832        assert!(!clusters[&0].is_empty());
833    }
834
835    #[test]
836    fn test_unified_resolver() {
837        use crate::eval::cdcr::Document;
838        use anno::{Entity, EntityType};
839
840        let encoder = HeuristicClusterEncoder::new(64);
841        let scorer = CosineMergeScorer::new();
842        let config = CrossContextConfig::default();
843
844        let resolver = UnifiedCrossContextResolver::new(encoder, scorer, config);
845
846        let docs =
847            vec![
848                Document::new("doc1", "Barack Obama gave a speech.").with_entities(vec![
849                    Entity::new("Barack Obama", EntityType::Person, 0, 12, 0.9),
850                ]),
851                Document::new("doc2", "Obama met with leaders.").with_entities(vec![Entity::new(
852                    "Obama",
853                    EntityType::Person,
854                    0,
855                    5,
856                    0.9,
857                )]),
858            ];
859
860        let result = resolver.resolve_documents(&docs);
861        // Should have clusters from both docs
862        assert!(!result.is_empty());
863    }
864
865    #[test]
866    fn test_neural_config_default() {
867        let config = NeuralClusterConfig::default();
868        assert_eq!(config.hidden_dim, 768);
869        assert_eq!(config.num_heads, 12);
870        assert!(!config.use_cls_pooling);
871    }
872
873    #[test]
874    fn test_incremental_adapter_empty() {
875        let windows: Vec<WindowOutput> = vec![];
876        let clusters = IncrementalCorefAdapter::windows_to_clusters(&windows);
877        assert!(clusters.is_empty());
878    }
879
880    #[test]
881    fn test_incremental_adapter_single_window() {
882        use crate::eval::coref::{CorefChain, Mention};
883        use anno::MentionType;
884
885        fn new_mention(text: &str, start: usize, end: usize, mt: MentionType) -> Mention {
886            Mention {
887                text: text.to_string(),
888                start,
889                end,
890                head_start: None,
891                head_end: None,
892                entity_type: None,
893                mention_type: Some(mt),
894            }
895        }
896
897        let chain = CorefChain::new(vec![
898            new_mention("Obama", 0, 5, MentionType::Proper),
899            new_mention("he", 20, 22, MentionType::Pronominal),
900        ]);
901
902        let window = WindowOutput::new(0, 0, 100, vec![chain]);
903        let clusters = IncrementalCorefAdapter::windows_to_clusters(&[window]);
904
905        assert_eq!(clusters.len(), 1);
906        assert_eq!(clusters[&0].len(), 1);
907        assert_eq!(clusters[&0][0].mentions.len(), 2);
908    }
909
910    #[test]
911    fn test_incremental_adapter_multi_window() {
912        use crate::eval::coref::{CorefChain, Mention};
913        use anno::MentionType;
914
915        fn new_mention(text: &str, start: usize, end: usize, mt: MentionType) -> Mention {
916            Mention {
917                text: text.to_string(),
918                start,
919                end,
920                head_start: None,
921                head_end: None,
922                entity_type: None,
923                mention_type: Some(mt),
924            }
925        }
926
927        let window1 = WindowOutput::new(
928            0,
929            0,
930            100,
931            vec![CorefChain::new(vec![new_mention(
932                "Obama",
933                0,
934                5,
935                MentionType::Proper,
936            )])],
937        );
938
939        let window2 = WindowOutput::new(
940            1,
941            80,
942            180,
943            vec![CorefChain::new(vec![new_mention(
944                "the President",
945                90,
946                103,
947                MentionType::Nominal,
948            )])],
949        );
950
951        let clusters = IncrementalCorefAdapter::windows_to_clusters(&[window1, window2]);
952
953        assert_eq!(clusters.len(), 2);
954        assert_eq!(clusters[&0].len(), 1);
955        assert_eq!(clusters[&1].len(), 1);
956    }
957
958    #[test]
959    fn test_long_document_resolution() {
960        use crate::eval::coref::{CorefChain, Mention};
961        use anno::MentionType;
962
963        fn new_mention(text: &str, start: usize, end: usize, mt: MentionType) -> Mention {
964            Mention {
965                text: text.to_string(),
966                start,
967                end,
968                head_start: None,
969                head_end: None,
970                entity_type: None,
971                mention_type: Some(mt),
972            }
973        }
974
975        let encoder = HeuristicClusterEncoder::new(64);
976        let scorer = CosineMergeScorer::new();
977        let config = CrossContextConfig {
978            merge_threshold: 0.3,
979            ..Default::default()
980        };
981
982        let resolver = UnifiedCrossContextResolver::new(encoder, scorer, config);
983
984        // Create overlapping windows with similar mentions
985        let window1 = WindowOutput::new(
986            0,
987            0,
988            1000,
989            vec![CorefChain::new(vec![new_mention(
990                "Barack Obama",
991                0,
992                12,
993                MentionType::Proper,
994            )])],
995        );
996
997        let window2 = WindowOutput::new(
998            1,
999            800,
1000            1800,
1001            vec![CorefChain::new(vec![new_mention(
1002                "Obama",
1003                900,
1004                905,
1005                MentionType::Proper,
1006            )])],
1007        );
1008
1009        let chains = resolver.resolve_long_document_windows(&[window1, window2]);
1010
1011        // Should produce some chains
1012        assert!(!chains.is_empty());
1013    }
1014}