Skip to main content

anno_eval/eval/
coref_loader.rs

1//! Coreference dataset loading and parsing.
2//!
3//! Provides specialized loaders for coreference datasets that return
4//! `CorefDocument` structures rather than NER annotations.
5//!
6//! # Supported Datasets
7//!
8//! | Dataset | Format | Size | Features |
9//! |---------|--------|------|----------|
10//! | GAP | TSV | 8,908 pairs | Gender-balanced pronoun resolution |
11//! | PreCo | JSON | ~38k docs | Large-scale, includes singletons |
12//! | Synthetic | Generated | Configurable | For testing metrics |
13//!
14//! # Example
15//!
16//! ```rust,ignore
17//! use anno_eval::eval::coref_loader::{CorefLoader, synthetic_coref_dataset};
18//!
19//! // Load GAP development set (requires eval feature for download)
20//! let loader = CorefLoader::new().unwrap();
21//! let docs = loader.load_gap().unwrap();
22//!
23//! // Or generate synthetic data for testing
24//! let synthetic = synthetic_coref_dataset(10);
25//! ```
26
27use super::coref::{CorefChain, CorefDocument, Mention, MentionType};
28use super::loader::DatasetId;
29use anno::{Error, Result};
30use serde::{Deserialize, Serialize};
31use std::collections::HashMap;
32use std::fs;
33use std::path::PathBuf;
34
35// =============================================================================
36// CorefUD (CoNLL-U + Entity brackets in MISC)
37// =============================================================================
38
39/// Parse CorefUD CoNLL-U format into coreference documents.
40///
41/// CorefUD encodes coreference in the CoNLL-U MISC column via the `Entity` attribute.
42/// The value of `Entity=` is a bracketed stream of mention boundary markers:
43/// - Opening marker at the first token of a mention: `Entity=(e5-person-...)`
44/// - Closing marker at the last token of a mention: `Entity=e5)`
45/// - One-token mentions can be encoded as a self-contained marker: `Entity=(e8-place-1)`
46/// - Multiple markers can be concatenated in one value: `Entity=(e8-place-1)e9)`
47/// - Discontinuous mentions may include part tags after the cluster id: `e10[1/2]`
48///
49/// This parser is intentionally conservative:
50/// - It extracts clusters and **contiguous character spans**.
51/// - It preserves `MentionType::Zero` for mentions that are empty nodes (ID with `.`).
52/// - It ignores bridging/split antecedents and other CorefUD extras.
53///
54/// Note: CorefUD supports discontinuous mentions. `anno` currently represents mentions
55/// as contiguous character spans, so discontinuous mentions are approximated by their
56/// minimal bounding span in surface character space.
57pub fn parse_corefud_conllu(content: &str) -> Result<Vec<CorefDocument>> {
58    #[derive(Debug, Clone)]
59    struct TokenSpan {
60        is_empty_node: bool,
61        entity_value: Option<String>,
62    }
63
64    #[derive(Debug, Clone)]
65    struct OpenMention {
66        start_char: usize,
67        entity_type: Option<String>,
68        is_empty_node: bool,
69    }
70
71    #[derive(Debug, Clone)]
72    enum EntityMark {
73        Open {
74            cluster: String,
75            entity_type: Option<String>,
76            self_close: bool,
77        },
78        Close {
79            cluster: String,
80        },
81    }
82
83    fn parse_misc_entity(misc: &str) -> Option<String> {
84        if misc.trim().is_empty() || misc.trim() == "_" {
85            return None;
86        }
87        for part in misc.split('|') {
88            if let Some(rest) = part.strip_prefix("Entity=") {
89                if !rest.is_empty() && rest != "_" {
90                    return Some(rest.to_string());
91                }
92            }
93        }
94        None
95    }
96
97    fn parse_space_after_no(misc: &str) -> bool {
98        if misc.trim().is_empty() || misc.trim() == "_" {
99            return false;
100        }
101        misc.split('|').any(|p| p == "SpaceAfter=No")
102    }
103
104    fn split_cluster_and_type(open_descriptor: &str) -> (String, Option<String>) {
105        // Descriptor example: "e5-person-1-1,2,4-new-coref"
106        // We only reliably extract:
107        // - cluster id: first field before '-'
108        // - entity type: second field (if present)
109        let mut parts = open_descriptor.splitn(3, '-');
110        let raw_cluster = parts.next().unwrap_or("").to_string();
111        let etype = parts.next().map(|s| s.to_string());
112
113        // Handle discontinuous tags like e10[1/2] by stripping bracket suffix.
114        let cluster = if let Some((base, _rest)) = raw_cluster.split_once('[') {
115            base.to_string()
116        } else {
117            raw_cluster
118        };
119        (cluster, etype)
120    }
121
122    fn split_cluster(close_descriptor: &str) -> String {
123        // Close descriptor example: "e9" (from "e9)")
124        // May include discontinuous tag suffix e10[1/2]
125        if let Some((base, _)) = close_descriptor.split_once('[') {
126            base.to_string()
127        } else {
128            close_descriptor.to_string()
129        }
130    }
131
132    fn parse_entity_marks(entity_value: &str) -> Vec<EntityMark> {
133        // Parse a stream of markers from left-to-right.
134        //
135        // Markers are either:
136        // - '(' + descriptor + ')'        => Open marker that self-closes (one-token mention)
137        // - '(' + descriptor (no ')')     => Open marker (mention continues)
138        // - descriptor + ')'              => Close marker
139        //
140        // The format allows concatenation: "(e8-place-1)e9)e7)"
141        let mut marks = Vec::new();
142        let mut i = 0usize;
143        let bytes = entity_value.as_bytes();
144
145        while i < bytes.len() {
146            match bytes[i] as char {
147                '(' => {
148                    i += 1;
149                    let start = i;
150                    // Descriptor ends at ')' (self-contained) OR at the next '(' (multiple opens).
151                    while i < bytes.len() && (bytes[i] as char) != ')' && (bytes[i] as char) != '('
152                    {
153                        i += 1;
154                    }
155                    if i < bytes.len() && (bytes[i] as char) == ')' {
156                        // Self-contained marker "(...)" (one-token mention)
157                        let descriptor = entity_value[start..i].to_string();
158                        let (cluster, etype) = split_cluster_and_type(&descriptor);
159                        marks.push(EntityMark::Open {
160                            cluster,
161                            entity_type: etype,
162                            self_close: true,
163                        });
164                        i += 1;
165                    } else {
166                        // No closing ')': open marker continues.
167                        // If we stopped because we hit another '(', allow parsing the next marker too.
168                        let descriptor = entity_value[start..i].to_string();
169                        let (cluster, etype) = split_cluster_and_type(&descriptor);
170                        marks.push(EntityMark::Open {
171                            cluster,
172                            entity_type: etype,
173                            self_close: false,
174                        });
175                        if i >= bytes.len() {
176                            break;
177                        }
178                    }
179                }
180                'e' => {
181                    // Parse closing marker: "e123...)" possibly repeated.
182                    let start = i;
183                    while i < bytes.len() && (bytes[i] as char) != ')' {
184                        if (bytes[i] as char) == '(' {
185                            break;
186                        }
187                        i += 1;
188                    }
189                    if i < bytes.len() && (bytes[i] as char) == ')' {
190                        let raw = entity_value[start..i].to_string();
191                        marks.push(EntityMark::Close {
192                            cluster: split_cluster(&raw),
193                        });
194                        i += 1;
195                    } else {
196                        break;
197                    }
198                }
199                _ => i += 1,
200            }
201        }
202
203        marks
204    }
205
206    fn extract_span_text(text: &str, start: usize, end: usize) -> String {
207        if end <= start {
208            return String::new();
209        }
210        text.chars().skip(start).take(end - start).collect()
211    }
212
213    // Document accumulators
214    let mut docs: Vec<CorefDocument> = Vec::new();
215    let mut doc_idx: usize = 0;
216    let mut current_doc_id: Option<String> = None;
217    let mut text = String::new();
218    let mut text_char_len: usize = 0;
219
220    let mut tokens: Vec<TokenSpan> = Vec::new();
221    let mut clusters: HashMap<String, Vec<Mention>> = HashMap::new();
222    let mut open: HashMap<String, Vec<OpenMention>> = HashMap::new();
223
224    let mut prev_space_after_no = false;
225
226    let flush_doc = |docs: &mut Vec<CorefDocument>,
227                     doc_idx: &mut usize,
228                     current_doc_id: &mut Option<String>,
229                     text: &mut String,
230                     clusters: &mut HashMap<String, Vec<Mention>>,
231                     open: &mut HashMap<String, Vec<OpenMention>>|
232     -> Result<()> {
233        if text.is_empty() && clusters.is_empty() {
234            *current_doc_id = None;
235            open.clear();
236            return Ok(());
237        }
238
239        if open.values().any(|stk| !stk.is_empty()) {
240            return Err(Error::InvalidInput(
241                "CorefUD parse error: document ended with unclosed Entity brackets".to_string(),
242            ));
243        }
244
245        // Build chains.
246        let mut coref_chains: Vec<CorefChain> = Vec::new();
247        for (cluster_id, mut mentions) in std::mem::take(clusters).into_iter() {
248            // Fill mention texts now that doc text is finalized.
249            for m in &mut mentions {
250                if m.mention_type == Some(MentionType::Zero) {
251                    m.text = String::new();
252                } else {
253                    m.text = extract_span_text(text, m.start, m.end);
254                }
255            }
256
257            // Convert "e123" -> 123 if possible.
258            let numeric_id = cluster_id.strip_prefix('e').and_then(|rest| {
259                rest.chars()
260                    .take_while(|c| c.is_ascii_digit())
261                    .collect::<String>()
262                    .parse::<u64>()
263                    .ok()
264            });
265
266            if let Some(cid) = numeric_id {
267                coref_chains.push(CorefChain::with_id(mentions, cid));
268            } else {
269                coref_chains.push(CorefChain::new(mentions));
270            }
271        }
272
273        if coref_chains.is_empty() {
274            return Err(Error::InvalidInput(
275                "CorefUD CoNLL-U contains no coreference chains".to_string(),
276            ));
277        }
278
279        let doc_id = current_doc_id
280            .clone()
281            .unwrap_or_else(|| format!("corefud_doc_{}", *doc_idx));
282        *doc_idx += 1;
283
284        docs.push(CorefDocument::with_id(
285            std::mem::take(text),
286            doc_id,
287            coref_chains,
288        ));
289
290        // Reset
291        *current_doc_id = None;
292        open.clear();
293        Ok(())
294    };
295
296    for raw_line in content.lines() {
297        let line = raw_line.trim_end();
298
299        // Document boundary
300        if line.starts_with("# newdoc") {
301            flush_doc(
302                &mut docs,
303                &mut doc_idx,
304                &mut current_doc_id,
305                &mut text,
306                &mut clusters,
307                &mut open,
308            )?;
309            tokens.clear();
310            text_char_len = 0;
311            prev_space_after_no = false;
312
313            // Parse optional doc id
314            if let Some(pos) = line.find("id") {
315                let maybe = line[pos..].split('=').nth(1).map(|s| s.trim());
316                if let Some(id) = maybe {
317                    if !id.is_empty() {
318                        current_doc_id = Some(id.to_string());
319                    }
320                }
321            }
322            continue;
323        }
324
325        // Comments
326        if line.starts_with('#') {
327            continue;
328        }
329
330        // Sentence boundary
331        if line.trim().is_empty() {
332            continue;
333        }
334
335        let fields: Vec<&str> = line.split('\t').collect();
336        if fields.len() < 2 {
337            continue;
338        }
339
340        let id_field = fields[0];
341        // Skip multi-word token lines (e.g., 1-2)
342        if id_field.contains('-') {
343            continue;
344        }
345
346        let is_empty_node = id_field.contains('.');
347        let form = fields.get(1).copied().unwrap_or("_");
348        let misc = fields.get(9).copied().unwrap_or("_");
349        let entity_value = parse_misc_entity(misc);
350        let space_after_no = parse_space_after_no(misc);
351
352        let (char_start, char_end) = if is_empty_node {
353            (text_char_len, text_char_len)
354        } else {
355            if !text.is_empty() && !prev_space_after_no {
356                text.push(' ');
357                text_char_len += 1;
358            }
359            let start = text_char_len;
360            text.push_str(form);
361            text_char_len += form.chars().count();
362            (start, text_char_len)
363        };
364
365        if !is_empty_node {
366            prev_space_after_no = space_after_no;
367        }
368
369        let token_idx = tokens.len();
370        tokens.push(TokenSpan {
371            is_empty_node,
372            entity_value,
373        });
374
375        // Apply entity boundary markers to build mentions.
376        if let Some(ref ev) = tokens[token_idx].entity_value {
377            let marks = parse_entity_marks(ev);
378            for mark in marks {
379                match mark {
380                    EntityMark::Open {
381                        cluster,
382                        entity_type,
383                        self_close,
384                    } => {
385                        if self_close {
386                            let mut m = Mention::new("", char_start, char_end);
387                            if tokens[token_idx].is_empty_node {
388                                m.mention_type = Some(MentionType::Zero);
389                            }
390                            if let Some(et) = entity_type {
391                                m.entity_type = Some(et);
392                            }
393                            clusters.entry(cluster).or_default().push(m);
394                        } else {
395                            open.entry(cluster).or_default().push(OpenMention {
396                                start_char: char_start,
397                                entity_type,
398                                is_empty_node: tokens[token_idx].is_empty_node,
399                            });
400                        }
401                    }
402                    EntityMark::Close { cluster } => {
403                        let Some(stack) = open.get_mut(&cluster) else {
404                            return Err(Error::InvalidInput(format!(
405                                "CorefUD parse error: closing Entity for {} with no open mention",
406                                cluster
407                            )));
408                        };
409                        let Some(opened) = stack.pop() else {
410                            return Err(Error::InvalidInput(format!(
411                                "CorefUD parse error: closing Entity for {} with empty stack",
412                                cluster
413                            )));
414                        };
415
416                        let mut m = Mention::new("", opened.start_char, char_end);
417                        if opened.is_empty_node
418                            && tokens[token_idx].is_empty_node
419                            && opened.start_char == char_end
420                        {
421                            m.mention_type = Some(MentionType::Zero);
422                        }
423                        if let Some(et) = opened.entity_type {
424                            m.entity_type = Some(et);
425                        }
426                        clusters.entry(cluster).or_default().push(m);
427                    }
428                }
429            }
430        }
431    }
432
433    // Final flush
434    flush_doc(
435        &mut docs,
436        &mut doc_idx,
437        &mut current_doc_id,
438        &mut text,
439        &mut clusters,
440        &mut open,
441    )?;
442
443    if docs.is_empty() {
444        return Err(Error::InvalidInput(
445            "CorefUD CoNLL-U contains no documents".to_string(),
446        ));
447    }
448
449    Ok(docs)
450}
451
452// =============================================================================
453// GAP Dataset Structures
454// =============================================================================
455
456/// A single GAP example (pronoun-name pair).
457#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct GapExample {
459    /// Unique identifier
460    pub id: String,
461    /// Full text context
462    pub text: String,
463    /// The pronoun to resolve
464    pub pronoun: String,
465    /// Character offset of pronoun
466    pub pronoun_offset: usize,
467    /// First candidate name (A)
468    pub name_a: String,
469    /// Character offset of name A
470    pub offset_a: usize,
471    /// Whether pronoun refers to A
472    pub coref_a: bool,
473    /// Second candidate name (B)
474    pub name_b: String,
475    /// Character offset of name B
476    pub offset_b: usize,
477    /// Whether pronoun refers to B
478    pub coref_b: bool,
479    /// Source URL (Wikipedia)
480    pub url: Option<String>,
481}
482
483impl GapExample {
484    /// Convert to coreference document.
485    ///
486    /// Creates chains based on the coreference labels.
487    #[must_use]
488    pub fn to_coref_document(&self) -> CorefDocument {
489        let mut chains = Vec::new();
490
491        // Create mention for pronoun
492        let pronoun_mention = Mention::with_type(
493            &self.pronoun,
494            self.pronoun_offset,
495            self.pronoun_offset + self.pronoun.len(),
496            MentionType::Pronominal,
497        );
498
499        // Create mentions for names
500        let mention_a = Mention::with_type(
501            &self.name_a,
502            self.offset_a,
503            self.offset_a + self.name_a.len(),
504            MentionType::Proper,
505        );
506
507        let mention_b = Mention::with_type(
508            &self.name_b,
509            self.offset_b,
510            self.offset_b + self.name_b.len(),
511            MentionType::Proper,
512        );
513
514        // Build chains based on coreference labels
515        if self.coref_a {
516            // Pronoun refers to A
517            chains.push(CorefChain::new(vec![mention_a, pronoun_mention.clone()]));
518            chains.push(CorefChain::singleton(mention_b));
519        } else if self.coref_b {
520            // Pronoun refers to B
521            chains.push(CorefChain::singleton(mention_a));
522            chains.push(CorefChain::new(vec![mention_b, pronoun_mention.clone()]));
523        } else {
524            // Neither (pronoun doesn't refer to either candidate)
525            chains.push(CorefChain::singleton(mention_a));
526            chains.push(CorefChain::singleton(mention_b));
527            chains.push(CorefChain::singleton(pronoun_mention));
528        }
529
530        CorefDocument::with_id(&self.text, &self.id, chains)
531    }
532}
533
534// =============================================================================
535// PreCo Dataset Structures
536// =============================================================================
537
538/// A PreCo document with coreference annotations.
539#[derive(Debug, Clone, Serialize, Deserialize)]
540pub struct PreCoDocument {
541    /// Document ID
542    pub id: String,
543    /// Sentences as token arrays
544    pub sentences: Vec<Vec<String>>,
545    /// Coreference mentions: (sentence_idx, start_token, end_token, cluster_id)
546    pub mentions: Vec<(usize, usize, usize, usize)>,
547}
548
549impl PreCoDocument {
550    /// Convert to coreference document.
551    #[must_use]
552    pub fn to_coref_document(&self) -> CorefDocument {
553        // Reconstruct text from sentences
554        let mut text = String::new();
555        let mut sentence_offsets: Vec<usize> = Vec::new();
556        let mut token_offsets: Vec<Vec<(usize, usize)>> = Vec::new();
557
558        for sentence in &self.sentences {
559            sentence_offsets.push(text.len());
560            let mut sent_offsets = Vec::new();
561
562            for (i, token) in sentence.iter().enumerate() {
563                if i > 0 {
564                    text.push(' ');
565                }
566                let start = text.len();
567                text.push_str(token);
568                let end = text.len();
569                sent_offsets.push((start, end));
570            }
571            text.push(' ');
572            token_offsets.push(sent_offsets);
573        }
574
575        // Group mentions by cluster
576        let mut clusters: HashMap<usize, Vec<Mention>> = HashMap::new();
577
578        for &(sent_idx, start_tok, end_tok, cluster_id) in &self.mentions {
579            if sent_idx >= token_offsets.len() {
580                continue;
581            }
582            let sent_tokens = &token_offsets[sent_idx];
583            if start_tok >= sent_tokens.len() || end_tok > sent_tokens.len() {
584                continue;
585            }
586
587            // Note: sent_tokens stores byte offsets (from text.len())
588            let byte_start = sent_tokens[start_tok].0;
589            let byte_end = sent_tokens[end_tok.saturating_sub(1).max(start_tok)].1;
590            let mention_text = text[byte_start..byte_end].to_string();
591
592            // Convert byte offsets to character offsets for Mention (which expects char offsets)
593            let char_start = text[..byte_start].chars().count();
594            let char_end = char_start + mention_text.chars().count();
595
596            let mention = Mention::new(mention_text, char_start, char_end);
597            clusters.entry(cluster_id).or_default().push(mention);
598        }
599
600        // Convert clusters to chains
601        let chains: Vec<CorefChain> = clusters
602            .into_iter()
603            .map(|(id, mentions)| CorefChain::with_id(mentions, id as u64))
604            .collect();
605
606        CorefDocument::with_id(text, &self.id, chains)
607    }
608}
609
610// =============================================================================
611// Coreference Loader (delegates to DatasetLoader)
612// =============================================================================
613
614/// Loader for coreference datasets.
615///
616/// This is a thin wrapper around `DatasetLoader` that provides coreference-specific
617/// loading methods. For most use cases, you can use `DatasetLoader::load_coref()` directly.
618pub struct CorefLoader {
619    inner: super::loader::DatasetLoader,
620}
621
622impl CorefLoader {
623    /// Create a new loader with default cache directory.
624    pub fn new() -> Result<Self> {
625        Ok(Self {
626            inner: super::loader::DatasetLoader::new()?,
627        })
628    }
629
630    /// Create loader with custom cache directory.
631    pub fn with_cache_dir(cache_dir: impl Into<PathBuf>) -> Result<Self> {
632        Ok(Self {
633            inner: super::loader::DatasetLoader::with_cache_dir(cache_dir)?,
634        })
635    }
636
637    /// Load GAP dataset as coreference documents.
638    pub fn load_gap(&self) -> Result<Vec<CorefDocument>> {
639        self.inner.load_coref(DatasetId::GAP)
640    }
641
642    /// Load GAP as raw examples (for detailed analysis).
643    pub fn load_gap_examples(&self) -> Result<Vec<GapExample>> {
644        let gap = super::loader::LoadableDatasetId::try_from(DatasetId::GAP)?;
645        let cache_path = self.inner.cache_path(gap);
646
647        if !cache_path.exists() {
648            return Err(Error::InvalidInput(format!(
649                "GAP dataset not cached at {:?}",
650                cache_path
651            )));
652        }
653
654        let content = fs::read_to_string(&cache_path)
655            .map_err(|e| Error::InvalidInput(format!("Failed to read {:?}: {}", cache_path, e)))?;
656
657        parse_gap_tsv(&content)
658    }
659
660    /// Load PreCo dataset as coreference documents.
661    pub fn load_preco(&self) -> Result<Vec<CorefDocument>> {
662        self.inner.load_coref(DatasetId::PreCo)
663    }
664
665    /// Load a CorefUD CoNLL-U file from an explicit local path (no caching, no network).
666    ///
667    /// This is the easiest way to run CorefUD experiments without wiring up downloads:
668    /// download/extract the desired `*.conllu` locally and point this method at it.
669    pub fn load_corefud_from_path(
670        &self,
671        path: impl AsRef<std::path::Path>,
672    ) -> Result<Vec<CorefDocument>> {
673        let path = path.as_ref();
674        let content = fs::read_to_string(path)
675            .map_err(|e| Error::InvalidInput(format!("Failed to read {:?}: {}", path, e)))?;
676        parse_corefud_conllu(&content)
677    }
678
679    /// Check if a coreference dataset is cached.
680    #[must_use]
681    pub fn is_cached(&self, id: DatasetId) -> bool {
682        match super::loader::LoadableDatasetId::try_from(id) {
683            Ok(loadable) => self.inner.is_cached(loadable),
684            Err(_) => false,
685        }
686    }
687
688    /// Get the underlying DatasetLoader.
689    #[must_use]
690    pub fn dataset_loader(&self) -> &super::loader::DatasetLoader {
691        &self.inner
692    }
693}
694
695impl Default for CorefLoader {
696    fn default() -> Self {
697        Self::new().expect("Failed to create CorefLoader")
698    }
699}
700
701// =============================================================================
702// Parsers
703// =============================================================================
704
705/// Parse GAP TSV format.
706///
707/// Format: `ID\tText\tPronoun\tPronoun-offset\tA\tA-offset\tA-coref\tB\tB-offset\tB-coref\tURL`
708///
709/// This function is public for use by `DatasetLoader`.
710pub fn parse_gap_tsv(content: &str) -> Result<Vec<GapExample>> {
711    let mut examples = Vec::new();
712    let mut first_line = true;
713
714    for line in content.lines() {
715        // Skip header
716        if first_line {
717            first_line = false;
718            continue;
719        }
720
721        let parts: Vec<&str> = line.split('\t').collect();
722        if parts.len() < 10 {
723            continue;
724        }
725
726        let id = parts[0].to_string();
727        let text = parts[1].to_string();
728        let pronoun = parts[2].to_string();
729        let pronoun_offset: usize = parts[3].parse().unwrap_or(0);
730        let name_a = parts[4].to_string();
731        let offset_a: usize = parts[5].parse().unwrap_or(0);
732        let coref_a = parts[6].to_lowercase() == "true";
733        let name_b = parts[7].to_string();
734        let offset_b: usize = parts[8].parse().unwrap_or(0);
735        let coref_b = parts[9].to_lowercase() == "true";
736        let url = parts.get(10).map(|s| s.to_string());
737
738        examples.push(GapExample {
739            id,
740            text,
741            pronoun,
742            pronoun_offset,
743            name_a,
744            offset_a,
745            coref_a,
746            name_b,
747            offset_b,
748            coref_b,
749            url,
750        });
751    }
752
753    Ok(examples)
754}
755
756/// Parse PreCo JSON format.
757/// Parse PreCo JSON format (public for use by DatasetLoader).
758pub fn parse_preco_json(content: &str) -> Result<Vec<PreCoDocument>> {
759    let parsed: serde_json::Value = serde_json::from_str(content)
760        .map_err(|e| Error::InvalidInput(format!("Invalid PreCo JSON: {}", e)))?;
761
762    let mut docs = Vec::new();
763
764    if let Some(doc_array) = parsed.as_array() {
765        for (idx, doc) in doc_array.iter().enumerate() {
766            // Extract sentences
767            let sentences: Vec<Vec<String>> = doc
768                .get("sentences")
769                .and_then(|s| s.as_array())
770                .map(|arr| {
771                    arr.iter()
772                        .filter_map(|sent| {
773                            sent.as_array().map(|tokens| {
774                                tokens
775                                    .iter()
776                                    .filter_map(|t| t.as_str().map(String::from))
777                                    .collect()
778                            })
779                        })
780                        .collect()
781                })
782                .unwrap_or_default();
783
784            // Extract mentions
785            let mentions: Vec<(usize, usize, usize, usize)> =
786                doc.get("mention_clusters")
787                    .and_then(|m| m.as_array())
788                    .map(|clusters| {
789                        clusters
790                            .iter()
791                            .enumerate()
792                            .flat_map(|(cluster_id, cluster)| {
793                                cluster.as_array().into_iter().flatten().filter_map(
794                                    move |mention| {
795                                        let arr = mention.as_array()?;
796                                        if arr.len() >= 3 {
797                                            Some((
798                                                arr[0].as_u64()? as usize,
799                                                arr[1].as_u64()? as usize,
800                                                arr[2].as_u64()? as usize,
801                                                cluster_id,
802                                            ))
803                                        } else {
804                                            None
805                                        }
806                                    },
807                                )
808                            })
809                            .collect()
810                    })
811                    .unwrap_or_default();
812
813            let id = doc
814                .get("id")
815                .and_then(|i| i.as_str())
816                .unwrap_or(&format!("doc_{}", idx))
817                .to_string();
818
819            docs.push(PreCoDocument {
820                id,
821                sentences,
822                mentions,
823            });
824        }
825    }
826
827    if docs.is_empty() {
828        return Err(Error::InvalidInput(
829            "PreCo JSON contains no valid documents".to_string(),
830        ));
831    }
832
833    Ok(docs)
834}
835
836// =============================================================================
837// Synthetic Coreference Data
838// =============================================================================
839
840/// Generate synthetic coreference documents for testing.
841///
842/// Useful for validating metrics without downloading real datasets.
843#[must_use]
844pub fn synthetic_coref_dataset(num_docs: usize) -> Vec<CorefDocument> {
845    let templates = [
846        // Simple pronoun resolution
847        (
848            "John Smith went to the store. He bought some milk.",
849            vec![
850                ("John Smith", 0, 10, 0),
851                ("He", 35, 37, 0),
852            ],
853        ),
854        // Multiple entities
855        (
856            "Mary called Bob. She asked him about the meeting.",
857            vec![
858                ("Mary", 0, 4, 0),
859                ("She", 17, 20, 0),
860                ("Bob", 12, 15, 1),
861                ("him", 27, 30, 1),
862            ],
863        ),
864        // Longer chain
865        (
866            "The CEO announced the merger. She said the company would benefit. The executive was confident.",
867            vec![
868                ("The CEO", 0, 7, 0),
869                ("She", 30, 33, 0),
870                ("The executive", 68, 81, 0),
871            ],
872        ),
873        // Nested mentions (company and its parts)
874        (
875            "Apple released a new iPhone. The tech giant's device sold well.",
876            vec![
877                ("Apple", 0, 5, 0),
878                ("The tech giant", 29, 43, 0),
879                ("iPhone", 21, 27, 1),
880                ("device", 46, 52, 1),
881            ],
882        ),
883        // Singletons
884        (
885            "The weather was nice. Sarah went for a walk in the park.",
886            vec![
887                ("The weather", 0, 11, 0),
888                ("Sarah", 22, 27, 1),
889                ("the park", 47, 55, 2),
890            ],
891        ),
892    ];
893
894    let mut docs = Vec::new();
895
896    for i in 0..num_docs {
897        let (text, mentions) = &templates[i % templates.len()];
898
899        // Group mentions by cluster
900        let mut clusters: HashMap<usize, Vec<Mention>> = HashMap::new();
901        for &(mention_text, start, end, cluster_id) in mentions {
902            let mention = Mention::new(mention_text, start, end);
903            clusters.entry(cluster_id).or_default().push(mention);
904        }
905
906        let chains: Vec<CorefChain> = clusters
907            .into_iter()
908            .map(|(id, mentions)| CorefChain::with_id(mentions, id as u64))
909            .collect();
910
911        docs.push(CorefDocument::with_id(
912            *text,
913            format!("synthetic_{}", i),
914            chains,
915        ));
916    }
917
918    docs
919}
920
921/// Generate domain-specific synthetic coreference documents.
922#[must_use]
923pub fn domain_specific_coref_dataset(domain: &str) -> Vec<CorefDocument> {
924    match domain {
925        "biomedical" => biomedical_coref_examples(),
926        "legal" => legal_coref_examples(),
927        "news" => news_coref_examples(),
928        _ => synthetic_coref_dataset(5),
929    }
930}
931
932/// Biomedical coreference examples.
933fn biomedical_coref_examples() -> Vec<CorefDocument> {
934    vec![
935        CorefDocument::with_id(
936            "BRCA1 is a tumor suppressor gene. It plays a role in DNA repair. The gene is frequently mutated in breast cancer.",
937            "bio_1",
938            vec![CorefChain::new(vec![
939                Mention::new("BRCA1", 0, 5),
940                Mention::new("It", 34, 36),
941                Mention::new("The gene", 62, 70),
942            ])],
943        ),
944        CorefDocument::with_id(
945            "The patient presented with chest pain. She was diagnosed with myocardial infarction. The woman received immediate treatment.",
946            "bio_2",
947            vec![
948                CorefChain::new(vec![
949                    Mention::new("The patient", 0, 11),
950                    Mention::new("She", 39, 42),
951                    Mention::new("The woman", 85, 94),
952                ]),
953                CorefChain::singleton(Mention::new("myocardial infarction", 62, 83)),
954            ],
955        ),
956        CorefDocument::with_id(
957            "Aspirin inhibits COX-1 and COX-2. The drug reduces inflammation. It is commonly used for pain relief.",
958            "bio_3",
959            vec![
960                CorefChain::new(vec![
961                    Mention::new("Aspirin", 0, 7),
962                    Mention::new("The drug", 35, 43),
963                    Mention::new("It", 65, 67),
964                ]),
965                CorefChain::singleton(Mention::new("COX-1", 17, 22)),
966                CorefChain::singleton(Mention::new("COX-2", 27, 32)),
967            ],
968        ),
969    ]
970}
971
972/// Legal coreference examples.
973fn legal_coref_examples() -> Vec<CorefDocument> {
974    vec![
975        CorefDocument::with_id(
976            "The defendant entered into a contract with the plaintiff. He failed to deliver the goods. The accused claimed force majeure.",
977            "legal_1",
978            vec![
979                CorefChain::new(vec![
980                    Mention::new("The defendant", 0, 13),
981                    Mention::new("He", 58, 60),
982                    Mention::new("The accused", 89, 100),
983                ]),
984                CorefChain::singleton(Mention::new("the plaintiff", 43, 56)),
985            ],
986        ),
987        CorefDocument::with_id(
988            "Article 5 of the Treaty governs this matter. It states that parties must negotiate in good faith. The provision has been interpreted broadly.",
989            "legal_2",
990            vec![CorefChain::new(vec![
991                Mention::new("Article 5 of the Treaty", 0, 23),
992                Mention::new("It", 45, 47),
993                Mention::new("The provision", 99, 112),
994            ])],
995        ),
996    ]
997}
998
999/// News coreference examples.
1000fn news_coref_examples() -> Vec<CorefDocument> {
1001    vec![
1002        CorefDocument::with_id(
1003            "President Biden met with Chancellor Scholz. The American leader discussed trade. He emphasized cooperation. Biden later held a press conference.",
1004            "news_1",
1005            vec![
1006                CorefChain::new(vec![
1007                    Mention::new("President Biden", 0, 14),
1008                    Mention::new("The American leader", 44, 63),
1009                    Mention::new("He", 81, 83),
1010                    Mention::new("Biden", 107, 112),
1011                ]),
1012                CorefChain::singleton(Mention::new("Chancellor Scholz", 25, 42)),
1013            ],
1014        ),
1015        CorefDocument::with_id(
1016            "Nvidia announced record quarterly earnings. The chipmaker exceeded expectations. Its stock rose 5% in after-hours trading.",
1017            "news_2",
1018            vec![
1019                CorefChain::new(vec![
1020                    Mention::new("Nvidia", 0, 6),
1021                    Mention::new("The chipmaker", 44, 57),
1022                    Mention::new("Its", 80, 83),
1023                ]),
1024            ],
1025        ),
1026        CorefDocument::with_id(
1027            "The hurricane made landfall in Florida. It caused widespread damage. The storm was Category 4. Authorities ordered evacuations before it arrived.",
1028            "news_3",
1029            vec![
1030                CorefChain::new(vec![
1031                    Mention::new("The hurricane", 0, 13),
1032                    Mention::new("It", 40, 42),
1033                    Mention::new("The storm", 68, 77),
1034                    Mention::new("it", 133, 135),
1035                ]),
1036            ],
1037        ),
1038    ]
1039}
1040
1041/// Generate adversarial coreference examples.
1042///
1043/// These stress-test edge cases in coreference metrics.
1044#[must_use]
1045pub fn adversarial_coref_examples() -> Vec<(CorefDocument, CorefDocument, &'static str)> {
1046    vec![
1047        // Over-clustering: system merges two distinct entities
1048        (
1049            CorefDocument::new(
1050                "John saw Mary. He waved.",
1051                vec![
1052                    CorefChain::new(vec![Mention::new("John", 0, 4), Mention::new("He", 15, 17)]),
1053                    CorefChain::singleton(Mention::new("Mary", 9, 13)),
1054                ],
1055            ),
1056            CorefDocument::new(
1057                "John saw Mary. He waved.",
1058                vec![CorefChain::new(vec![
1059                    Mention::new("John", 0, 4),
1060                    Mention::new("Mary", 9, 13),
1061                    Mention::new("He", 15, 17),
1062                ])],
1063            ),
1064            "over-clustering",
1065        ),
1066        // Under-clustering: system splits one entity
1067        (
1068            CorefDocument::new(
1069                "Barack Obama gave a speech. The president was eloquent. Obama smiled.",
1070                vec![CorefChain::new(vec![
1071                    Mention::new("Barack Obama", 0, 12),
1072                    Mention::new("The president", 28, 41),
1073                    Mention::new("Obama", 56, 61),
1074                ])],
1075            ),
1076            CorefDocument::new(
1077                "Barack Obama gave a speech. The president was eloquent. Obama smiled.",
1078                vec![
1079                    CorefChain::new(vec![
1080                        Mention::new("Barack Obama", 0, 12),
1081                        Mention::new("Obama", 56, 61),
1082                    ]),
1083                    CorefChain::singleton(Mention::new("The president", 28, 41)),
1084                ],
1085            ),
1086            "under-clustering",
1087        ),
1088        // Missed mention: system finds fewer mentions
1089        (
1090            CorefDocument::new(
1091                "The dog ran. It was fast. The animal stopped.",
1092                vec![CorefChain::new(vec![
1093                    Mention::new("The dog", 0, 7),
1094                    Mention::new("It", 13, 15),
1095                    Mention::new("The animal", 26, 36),
1096                ])],
1097            ),
1098            CorefDocument::new(
1099                "The dog ran. It was fast. The animal stopped.",
1100                vec![CorefChain::new(vec![
1101                    Mention::new("The dog", 0, 7),
1102                    Mention::new("It", 13, 15),
1103                ])], // Missing "The animal"
1104            ),
1105            "missed-mention",
1106        ),
1107        // All singletons vs all in one cluster
1108        (
1109            CorefDocument::new(
1110                "A B C",
1111                vec![
1112                    CorefChain::singleton(Mention::new("A", 0, 1)),
1113                    CorefChain::singleton(Mention::new("B", 2, 3)),
1114                    CorefChain::singleton(Mention::new("C", 4, 5)),
1115                ],
1116            ),
1117            CorefDocument::new(
1118                "A B C",
1119                vec![CorefChain::new(vec![
1120                    Mention::new("A", 0, 1),
1121                    Mention::new("B", 2, 3),
1122                    Mention::new("C", 4, 5),
1123                ])],
1124            ),
1125            "singletons-vs-one-cluster",
1126        ),
1127    ]
1128}
1129
1130// =============================================================================
1131// BookCoref Support
1132// =============================================================================
1133
1134/// Parse BookCoref JSON/JSONL format.
1135///
1136/// BookCoref (Martinelli et al. 2025) provides book-scale coreference data.
1137///
1138/// The format follows OntoNotes-style with character metadata:
1139/// ```json
1140/// {
1141///   "doc_key": "pride_and_prejudice_1342",
1142///   "gutenberg_key": "1342",
1143///   "sentences": [["CHAPTER", "I."], ["It", "is", "a", "truth", ...], ...],
1144///   "clusters": [[[79,80], [81,82], ...], [[2727,2728], ...], ...],
1145///   "characters": [{"name": "Mr Bennet", "cluster": [[79,80], ...]}, ...]
1146/// }
1147/// ```
1148///
1149/// - `sentences`: nested arrays of tokens (word-tokenized)
1150/// - `clusters`: list of clusters, each cluster is list of [start, end] token spans (inclusive)
1151/// - `characters`: optional character metadata (not used for coreference eval)
1152///
1153/// # Errors
1154///
1155/// Returns error if JSON is malformed or has invalid structure.
1156pub fn parse_bookcoref_json(content: &str) -> Result<Vec<CorefDocument>> {
1157    let mut documents = Vec::new();
1158
1159    // Parse as JSONL (one JSON object per line) or JSON array
1160    if content.trim().starts_with('[') {
1161        // JSON array format
1162        let parsed: Vec<serde_json::Value> = serde_json::from_str(content).map_err(|e| {
1163            Error::InvalidInput(format!("Failed to parse BookCoref JSON array: {}", e))
1164        })?;
1165        for item in parsed {
1166            if let Some(doc) = parse_bookcoref_item(&item)? {
1167                documents.push(doc);
1168            }
1169        }
1170    } else {
1171        // JSONL format - one JSON per line
1172        for line in content.lines() {
1173            let line = line.trim();
1174            if line.is_empty() {
1175                continue;
1176            }
1177
1178            let item: serde_json::Value = serde_json::from_str(line).map_err(|e| {
1179                Error::InvalidInput(format!("Failed to parse BookCoref JSONL: {}", e))
1180            })?;
1181
1182            if let Some(doc) = parse_bookcoref_item(&item)? {
1183                documents.push(doc);
1184            }
1185        }
1186    }
1187
1188    if documents.is_empty() {
1189        return Err(Error::InvalidInput(
1190            "BookCoref content contains no valid documents".to_string(),
1191        ));
1192    }
1193
1194    Ok(documents)
1195}
1196
1197/// Parse a single BookCoref item.
1198fn parse_bookcoref_item(item: &serde_json::Value) -> Result<Option<CorefDocument>> {
1199    // Get sentences array
1200    let sentences = match item.get("sentences").and_then(|v| v.as_array()) {
1201        Some(s) => s,
1202        None => return Ok(None),
1203    };
1204
1205    // Get clusters array
1206    let clusters = match item.get("clusters").and_then(|v| v.as_array()) {
1207        Some(c) => c,
1208        None => return Ok(None),
1209    };
1210
1211    // Flatten sentences to get tokens and build token-to-char offset map
1212    let mut tokens: Vec<String> = Vec::new();
1213    for sentence in sentences {
1214        if let Some(sent_tokens) = sentence.as_array() {
1215            for token in sent_tokens {
1216                if let Some(t) = token.as_str() {
1217                    tokens.push(t.to_string());
1218                }
1219            }
1220        }
1221    }
1222
1223    if tokens.is_empty() {
1224        return Ok(None);
1225    }
1226
1227    // Build text and token offset map
1228    // We need to reconstruct text from tokens (space-separated)
1229    let mut text = String::new();
1230    let mut token_char_starts: Vec<usize> = Vec::new();
1231    let mut token_char_ends: Vec<usize> = Vec::new();
1232
1233    for (i, token) in tokens.iter().enumerate() {
1234        if i > 0 {
1235            text.push(' ');
1236        }
1237        let start = text.chars().count();
1238        text.push_str(token);
1239        let end = text.chars().count();
1240        token_char_starts.push(start);
1241        token_char_ends.push(end);
1242    }
1243
1244    // Parse clusters - each cluster is a list of [start_token, end_token] spans (inclusive)
1245    let mut coref_chains = Vec::new();
1246    for cluster in clusters {
1247        if let Some(spans) = cluster.as_array() {
1248            let mut mentions = Vec::new();
1249            for span in spans {
1250                if let Some(span_arr) = span.as_array() {
1251                    if span_arr.len() >= 2 {
1252                        let start_tok = span_arr[0].as_u64().unwrap_or(0) as usize;
1253                        let end_tok = span_arr[1].as_u64().unwrap_or(0) as usize;
1254
1255                        // Convert token indices to char offsets
1256                        if start_tok < token_char_starts.len() && end_tok < token_char_ends.len() {
1257                            let char_start = token_char_starts[start_tok];
1258                            let char_end = token_char_ends[end_tok];
1259
1260                            // Extract mention text
1261                            let mention_text: String = text
1262                                .chars()
1263                                .skip(char_start)
1264                                .take(char_end - char_start)
1265                                .collect();
1266
1267                            mentions.push(Mention::new(&mention_text, char_start, char_end));
1268                        }
1269                    }
1270                }
1271            }
1272
1273            if !mentions.is_empty() {
1274                coref_chains.push(CorefChain::new(mentions));
1275            }
1276        }
1277    }
1278
1279    Ok(Some(CorefDocument::new(&text, coref_chains)))
1280}
1281
1282// =============================================================================
1283// ECB+ Coreference Parser
1284// =============================================================================
1285
1286/// Parse ECB+ coreference from any supported format.
1287///
1288/// Dispatches to the XML zip parser if `raw_bytes` starts with ZIP magic bytes,
1289/// otherwise falls back to the legacy CSV sentence-index parser.
1290pub fn parse_ecb_plus_coref(content: &str) -> Result<Vec<CorefDocument>> {
1291    // Check for ZIP magic bytes at the start
1292    if content.as_bytes().starts_with(b"PK\x03\x04") {
1293        return parse_ecb_plus_zip(content.as_bytes());
1294    }
1295    parse_ecb_plus_sentence_index(content)
1296}
1297
1298/// Parse ECB+ from the real XML zip archive (`ECB+.zip`).
1299///
1300/// The zip contains files like `ECB+/{topic}/{topic}_{doc}.xml`.
1301/// Each XML file has:
1302/// - `<token t_id="N" sentence="S" number="P">text</token>` elements
1303/// - `<Markables>` section with mention elements (ACTION_*, HUMAN_*, etc.)
1304/// - `<Relations>` section with `CROSS_DOC_COREF` linking mentions to clusters
1305pub fn parse_ecb_plus_zip(data: &[u8]) -> Result<Vec<CorefDocument>> {
1306    use std::io::Cursor;
1307
1308    let cursor = Cursor::new(data);
1309    let mut archive = zip::ZipArchive::new(cursor)
1310        .map_err(|e| Error::InvalidInput(format!("Failed to open ECB+ zip: {}", e)))?;
1311
1312    let mut all_docs: Vec<CorefDocument> = Vec::new();
1313
1314    // Collect file names first (borrow checker)
1315    let file_names: Vec<String> = (0..archive.len())
1316        .filter_map(|i| {
1317            let f = archive.by_index(i).ok()?;
1318            let name = f.name().to_string();
1319            if name.ends_with(".xml") {
1320                Some(name)
1321            } else {
1322                None
1323            }
1324        })
1325        .collect();
1326
1327    for name in &file_names {
1328        let mut file = archive
1329            .by_name(name)
1330            .map_err(|e| Error::InvalidInput(format!("Failed to read {} from zip: {}", name, e)))?;
1331
1332        let mut xml_content = String::new();
1333        std::io::Read::read_to_string(&mut file, &mut xml_content)
1334            .map_err(|e| Error::InvalidInput(format!("Failed to read XML {}: {}", name, e)))?;
1335
1336        // Extract topic and doc name from path like "ECB+/1/1_1ecb.xml"
1337        let parts: Vec<&str> = name.split('/').collect();
1338        let (topic, doc_name) = if parts.len() >= 3 {
1339            (
1340                parts[parts.len() - 2].to_string(),
1341                parts[parts.len() - 1].trim_end_matches(".xml").to_string(),
1342            )
1343        } else if let Some(fname) = name.split('/').next_back() {
1344            let base = fname.trim_end_matches(".xml");
1345            // Try to extract topic from filename like "1_1ecb"
1346            if let Some((t, _)) = base.split_once('_') {
1347                (t.to_string(), base.to_string())
1348            } else {
1349                ("unknown".to_string(), base.to_string())
1350            }
1351        } else {
1352            continue;
1353        };
1354
1355        match parse_ecb_plus_xml(&xml_content, &topic, &doc_name) {
1356            Ok(doc) => all_docs.push(doc),
1357            Err(e) => {
1358                log::debug!("Skipping {}: {}", name, e);
1359            }
1360        }
1361    }
1362
1363    if all_docs.is_empty() {
1364        return Err(Error::InvalidInput(
1365            "ECB+ zip contains no parseable XML documents".to_string(),
1366        ));
1367    }
1368
1369    // Sort by (topic, doc) for deterministic order
1370    all_docs.sort_by(|a, b| a.doc_id.cmp(&b.doc_id));
1371    Ok(all_docs)
1372}
1373
1374/// Parse a single ECB+ XML file into a `CorefDocument`.
1375///
1376/// ECB+ XML structure:
1377/// ```xml
1378/// <Document doc_name="1_1ecb" doc_id="1_1ecb.xml.xml">
1379///   <token t_id="1" sentence="0" number="0">Token</token>
1380///   ...
1381///   <Markables>
1382///     <ACTION_OCCURRENCE m_id="30" ...>
1383///       <token_anchor t_id="5"/>
1384///     </ACTION_OCCURRENCE>
1385///   </Markables>
1386///   <Relations>
1387///     <CROSS_DOC_COREF r_id="1" note="30001">
1388///       <source m_id="30"/>
1389///       <target m_id="31"/>
1390///     </CROSS_DOC_COREF>
1391///   </Relations>
1392/// </Document>
1393/// ```
1394fn parse_ecb_plus_xml(xml: &str, topic: &str, doc_name: &str) -> Result<CorefDocument> {
1395    use quick_xml::events::Event;
1396    use quick_xml::Reader;
1397    use std::collections::{BTreeMap, BTreeSet};
1398
1399    // Token: t_id -> (sentence, number, text)
1400    let mut tokens: BTreeMap<u32, (u32, u32, String)> = BTreeMap::new();
1401    // Mention: m_id -> vec of t_ids
1402    let mut mentions: HashMap<u32, Vec<u32>> = HashMap::new();
1403    // Cross-doc cluster: cluster_note -> set of m_ids
1404    let mut clusters: HashMap<String, BTreeSet<u32>> = HashMap::new();
1405
1406    let mut reader = Reader::from_str(xml);
1407    reader.config_mut().trim_text(true);
1408
1409    let mut in_token = false;
1410    let mut cur_t_id = 0u32;
1411    let mut cur_sentence = 0u32;
1412    let mut cur_number = 0u32;
1413    let mut current_token_text = String::new();
1414
1415    let mut in_markable = false;
1416    let mut cur_m_id = 0u32;
1417    let mut current_mention_tokens: Vec<u32> = Vec::new();
1418
1419    let mut in_coref = false;
1420    let mut cur_coref_note = String::new();
1421    let mut current_coref_mentions: Vec<u32> = Vec::new();
1422
1423    fn get_attr(e: &quick_xml::events::BytesStart<'_>, name: &str) -> Option<String> {
1424        e.attributes().flatten().find_map(|a| {
1425            if a.key.as_ref() == name.as_bytes() {
1426                Some(String::from_utf8_lossy(&a.value).to_string())
1427            } else {
1428                None
1429            }
1430        })
1431    }
1432
1433    fn is_markable_tag(name: &[u8]) -> bool {
1434        name.starts_with(b"ACTION_")
1435            || name.starts_with(b"HUMAN_")
1436            || name.starts_with(b"LOC_")
1437            || name.starts_with(b"TIME_")
1438            || name.starts_with(b"NEG_")
1439    }
1440
1441    #[allow(clippy::too_many_arguments)]
1442    fn handle_start(
1443        e: &quick_xml::events::BytesStart<'_>,
1444        in_token: &mut bool,
1445        cur_t_id: &mut u32,
1446        cur_sentence: &mut u32,
1447        cur_number: &mut u32,
1448        current_token_text: &mut String,
1449        in_markable: &mut bool,
1450        cur_m_id: &mut u32,
1451        current_mention_tokens: &mut Vec<u32>,
1452        in_coref: &mut bool,
1453        cur_coref_note: &mut String,
1454        current_coref_mentions: &mut Vec<u32>,
1455    ) {
1456        let tag = e.name();
1457        let tag_bytes = tag.as_ref();
1458
1459        if tag_bytes == b"token" {
1460            *in_token = true;
1461            *cur_t_id = get_attr(e, "t_id")
1462                .and_then(|v| v.parse().ok())
1463                .unwrap_or(0);
1464            *cur_sentence = get_attr(e, "sentence")
1465                .and_then(|v| v.parse().ok())
1466                .unwrap_or(0);
1467            *cur_number = get_attr(e, "number")
1468                .and_then(|v| v.parse().ok())
1469                .unwrap_or(0);
1470            current_token_text.clear();
1471        } else if tag_bytes == b"token_anchor" {
1472            if let Some(tid) = get_attr(e, "t_id").and_then(|v| v.parse::<u32>().ok()) {
1473                current_mention_tokens.push(tid);
1474            }
1475        } else if tag_bytes == b"source" || tag_bytes == b"target" {
1476            if let Some(mid) = get_attr(e, "m_id").and_then(|v| v.parse::<u32>().ok()) {
1477                current_coref_mentions.push(mid);
1478            }
1479        } else if tag_bytes == b"CROSS_DOC_COREF" {
1480            *in_coref = true;
1481            *cur_coref_note = get_attr(e, "note").unwrap_or_default();
1482            current_coref_mentions.clear();
1483        } else if is_markable_tag(tag_bytes) {
1484            *in_markable = true;
1485            *cur_m_id = get_attr(e, "m_id")
1486                .and_then(|v| v.parse().ok())
1487                .unwrap_or(0);
1488            current_mention_tokens.clear();
1489        }
1490    }
1491
1492    let mut buf = Vec::new();
1493    loop {
1494        match reader.read_event_into(&mut buf) {
1495            Ok(Event::Start(ref e)) => {
1496                handle_start(
1497                    e,
1498                    &mut in_token,
1499                    &mut cur_t_id,
1500                    &mut cur_sentence,
1501                    &mut cur_number,
1502                    &mut current_token_text,
1503                    &mut in_markable,
1504                    &mut cur_m_id,
1505                    &mut current_mention_tokens,
1506                    &mut in_coref,
1507                    &mut cur_coref_note,
1508                    &mut current_coref_mentions,
1509                );
1510            }
1511            Ok(Event::Empty(ref e)) => {
1512                // Self-closing tags like <token_anchor t_id="5"/>
1513                handle_start(
1514                    e,
1515                    &mut in_token,
1516                    &mut cur_t_id,
1517                    &mut cur_sentence,
1518                    &mut cur_number,
1519                    &mut current_token_text,
1520                    &mut in_markable,
1521                    &mut cur_m_id,
1522                    &mut current_mention_tokens,
1523                    &mut in_coref,
1524                    &mut cur_coref_note,
1525                    &mut current_coref_mentions,
1526                );
1527                // For empty markable elements, flush immediately
1528                let name = e.name();
1529                let tag_bytes = name.as_ref();
1530                if is_markable_tag(tag_bytes) && !current_mention_tokens.is_empty() {
1531                    mentions.insert(cur_m_id, current_mention_tokens.clone());
1532                    current_mention_tokens.clear();
1533                    in_markable = false;
1534                }
1535                // Empty source/target already handled above
1536            }
1537            Ok(Event::Text(ref e)) if in_token => {
1538                current_token_text.push_str(&e.unescape().unwrap_or_default());
1539            }
1540            Ok(Event::End(ref e)) => {
1541                let name = e.name();
1542                let tag_bytes = name.as_ref();
1543                if tag_bytes == b"token" && in_token {
1544                    tokens.insert(
1545                        cur_t_id,
1546                        (cur_sentence, cur_number, current_token_text.clone()),
1547                    );
1548                    in_token = false;
1549                } else if tag_bytes == b"CROSS_DOC_COREF" && in_coref {
1550                    if !cur_coref_note.is_empty() {
1551                        let entry = clusters.entry(cur_coref_note.clone()).or_default();
1552                        for mid in &current_coref_mentions {
1553                            entry.insert(*mid);
1554                        }
1555                    }
1556                    current_coref_mentions.clear();
1557                    in_coref = false;
1558                } else if is_markable_tag(tag_bytes) && in_markable {
1559                    if !current_mention_tokens.is_empty() {
1560                        mentions.insert(cur_m_id, current_mention_tokens.clone());
1561                    }
1562                    current_mention_tokens.clear();
1563                    in_markable = false;
1564                }
1565            }
1566            Ok(Event::Eof) => break,
1567            Err(e) => {
1568                return Err(Error::InvalidInput(format!(
1569                    "XML parse error in {}: {}",
1570                    doc_name, e
1571                )));
1572            }
1573            _ => {}
1574        }
1575        buf.clear();
1576    }
1577
1578    if tokens.is_empty() {
1579        return Err(Error::InvalidInput(format!(
1580            "ECB+ XML {} contains no tokens",
1581            doc_name
1582        )));
1583    }
1584
1585    // Reconstruct text from tokens (sorted by t_id)
1586    let mut text = String::new();
1587    let mut token_char_starts: BTreeMap<u32, usize> = BTreeMap::new();
1588    let mut token_char_ends: BTreeMap<u32, usize> = BTreeMap::new();
1589
1590    for (&t_id, (_sent, _num, tok_text)) in &tokens {
1591        if !text.is_empty() {
1592            text.push(' ');
1593        }
1594        let start = text.chars().count();
1595        text.push_str(tok_text);
1596        let end = text.chars().count();
1597        token_char_starts.insert(t_id, start);
1598        token_char_ends.insert(t_id, end);
1599    }
1600
1601    // Build coref chains from clusters
1602    let mut coref_chains: Vec<CorefChain> = Vec::new();
1603
1604    let mut cluster_keys: Vec<_> = clusters.keys().cloned().collect();
1605    cluster_keys.sort();
1606
1607    for cluster_key in cluster_keys {
1608        let m_ids = &clusters[&cluster_key];
1609        let mut chain_mentions: Vec<Mention> = Vec::new();
1610
1611        for &m_id in m_ids {
1612            if let Some(t_ids) = mentions.get(&m_id) {
1613                if t_ids.is_empty() {
1614                    continue;
1615                }
1616                // Span = min token start to max token end
1617                let start = t_ids
1618                    .iter()
1619                    .filter_map(|tid| token_char_starts.get(tid))
1620                    .min()
1621                    .copied();
1622                let end = t_ids
1623                    .iter()
1624                    .filter_map(|tid| token_char_ends.get(tid))
1625                    .max()
1626                    .copied();
1627
1628                if let (Some(s), Some(e)) = (start, end) {
1629                    let mention_text: String = text.chars().skip(s).take(e - s).collect();
1630                    chain_mentions.push(Mention {
1631                        text: mention_text,
1632                        start: s,
1633                        end: e,
1634                        head_start: None,
1635                        head_end: None,
1636                        entity_type: Some("EVENT".to_string()),
1637                        mention_type: Some(MentionType::Proper),
1638                    });
1639                }
1640            }
1641        }
1642
1643        if !chain_mentions.is_empty() {
1644            let cid = cluster_key.parse::<u64>().unwrap_or(0);
1645            coref_chains.push(CorefChain::with_id(chain_mentions, cid));
1646        }
1647    }
1648
1649    let doc_id = format!("{}_{}", topic, doc_name);
1650    Ok(CorefDocument::with_id(&text, doc_id, coref_chains))
1651}
1652
1653/// Parse ECB+ sentence-index CSV into `CorefDocument`s (legacy fallback).
1654///
1655/// The ECB+ CSV (`ECBplus_coreference_sentences.csv`) has rows per token with columns:
1656/// `Topic,File,Sentence Number,Token Number,Token,Lemma,Event Mention,Coreference Chain`
1657///
1658/// Documents are identified by `(topic, file)` pairs. Tokens are grouped into
1659/// sentences, and coreference chains are built from the `Coreference Chain` column.
1660/// A non-empty chain value indicates the token belongs to that coref chain.
1661///
1662/// Returns one `CorefDocument` per (topic, file).
1663fn parse_ecb_plus_sentence_index(content: &str) -> Result<Vec<CorefDocument>> {
1664    // (topic, file) -> DocAcc
1665    let mut docs: HashMap<(String, String), ecb_plus_acc::DocAcc> = HashMap::new();
1666
1667    let mut lines = content.lines();
1668
1669    // Skip header line
1670    if let Some(header) = lines.next() {
1671        // Validate it looks like an ECB+ header
1672        let lower = header.to_lowercase();
1673        if !lower.contains("token") && !lower.contains("topic") {
1674            // Not a header -- could be data; we'll be lenient and try parsing it below
1675            // by not consuming it (but we already called next(), so re-parse this line)
1676            parse_ecb_plus_line(header, &mut docs);
1677        }
1678    }
1679
1680    for line in lines {
1681        parse_ecb_plus_line(line, &mut docs);
1682    }
1683
1684    if docs.is_empty() {
1685        return Err(Error::InvalidInput(
1686            "ECB+ CSV contains no valid token rows".to_string(),
1687        ));
1688    }
1689
1690    // Convert accumulated data into CorefDocuments
1691    let mut result: Vec<CorefDocument> = Vec::new();
1692
1693    // Sort by key for deterministic order
1694    let mut doc_keys: Vec<_> = docs.keys().cloned().collect();
1695    doc_keys.sort();
1696
1697    for key in doc_keys {
1698        let acc = docs.remove(&key).unwrap();
1699        let (topic, file) = &key;
1700
1701        // Reconstruct text from tokens in order
1702        let mut text = String::new();
1703        let mut token_char_offsets: HashMap<(u32, u32), (usize, usize)> = HashMap::new();
1704
1705        for (&(sent, tok), token_text) in &acc.tokens {
1706            let start = text.chars().count();
1707            text.push_str(token_text);
1708            let end = text.chars().count();
1709            token_char_offsets.insert((sent, tok), (start, end));
1710            text.push(' ');
1711        }
1712
1713        // Build coref chains from chain mappings
1714        let mut coref_chains: Vec<CorefChain> = Vec::new();
1715        let mut chain_ids: Vec<_> = acc.chains.keys().cloned().collect();
1716        chain_ids.sort();
1717
1718        for chain_id in chain_ids {
1719            let token_positions = &acc.chains[&chain_id];
1720            let mentions: Vec<Mention> = token_positions
1721                .iter()
1722                .filter_map(|pos| {
1723                    let (start, end) = token_char_offsets.get(pos)?;
1724                    let token_text = acc.tokens.get(pos)?;
1725                    Some(Mention {
1726                        text: token_text.clone(),
1727                        start: *start,
1728                        end: *end,
1729                        head_start: None,
1730                        head_end: None,
1731                        entity_type: Some("EVENT".to_string()),
1732                        mention_type: Some(MentionType::Proper),
1733                    })
1734                })
1735                .collect();
1736
1737            if !mentions.is_empty() {
1738                coref_chains.push(CorefChain::new(mentions));
1739            }
1740        }
1741
1742        // Encode topic in doc_id so downstream can extract it via split('_')
1743        let doc = CorefDocument::with_id(&text, format!("{}_{}", topic, file), coref_chains);
1744        result.push(doc);
1745    }
1746
1747    Ok(result)
1748}
1749
1750/// Parse a single ECB+ CSV line into the document accumulators.
1751fn parse_ecb_plus_line(line: &str, docs: &mut HashMap<(String, String), ecb_plus_acc::DocAcc>) {
1752    let line = line.trim();
1753    if line.is_empty() {
1754        return;
1755    }
1756
1757    // CSV split (simple: ECB+ doesn't have quoted fields with commas)
1758    let parts: Vec<&str> = line.split(',').collect();
1759    if parts.len() < 5 {
1760        return;
1761    }
1762
1763    let topic = parts[0].trim().to_string();
1764    let file = parts[1].trim().to_string();
1765    let sent_num: u32 = match parts[2].trim().parse() {
1766        Ok(n) => n,
1767        Err(_) => return, // Skip non-numeric (e.g. malformed rows)
1768    };
1769    let tok_num: u32 = match parts[3].trim().parse() {
1770        Ok(n) => n,
1771        Err(_) => return,
1772    };
1773    let token = parts[4].trim().to_string();
1774
1775    // Coreference chain is typically the last column (index 7), but may vary.
1776    // Look for a non-empty chain value in columns after the token.
1777    let chain_id = parts
1778        .get(7)
1779        .or_else(|| parts.get(6))
1780        .map(|s| s.trim())
1781        .filter(|s| !s.is_empty() && *s != "-" && *s != "_")
1782        .map(|s| s.to_string());
1783
1784    let acc = docs.entry((topic, file)).or_default();
1785    acc.tokens.insert((sent_num, tok_num), token);
1786    if let Some(cid) = chain_id {
1787        acc.chains.entry(cid).or_default().push((sent_num, tok_num));
1788    }
1789}
1790
1791/// Internal types for ECB+ accumulation (avoids polluting module namespace).
1792mod ecb_plus_acc {
1793    use std::collections::{BTreeMap, HashMap};
1794
1795    #[derive(Default)]
1796    pub struct DocAcc {
1797        pub tokens: BTreeMap<(u32, u32), String>,
1798        pub chains: HashMap<String, Vec<(u32, u32)>>,
1799    }
1800}
1801
1802// =============================================================================
1803// Tests
1804// =============================================================================
1805
1806#[cfg(test)]
1807mod tests {
1808    use super::*;
1809
1810    #[test]
1811    fn test_gap_example_to_coref() {
1812        let example = GapExample {
1813            id: "test-1".to_string(),
1814            text: "John saw Mary. He waved.".to_string(),
1815            pronoun: "He".to_string(),
1816            pronoun_offset: 15,
1817            name_a: "John".to_string(),
1818            offset_a: 0,
1819            coref_a: true,
1820            name_b: "Mary".to_string(),
1821            offset_b: 9,
1822            coref_b: false,
1823            url: None,
1824        };
1825
1826        let doc = example.to_coref_document();
1827        assert_eq!(doc.mention_count(), 3);
1828        assert_eq!(doc.chain_count(), 2); // John+He, Mary
1829    }
1830
1831    #[test]
1832    fn test_gap_example_mention_types() {
1833        use crate::eval::coref::MentionType;
1834
1835        let example = GapExample {
1836            id: "test-2".to_string(),
1837            text: "Alice met Bob. She smiled.".to_string(),
1838            pronoun: "She".to_string(),
1839            pronoun_offset: 15,
1840            name_a: "Alice".to_string(),
1841            offset_a: 0,
1842            coref_a: true,
1843            name_b: "Bob".to_string(),
1844            offset_b: 10,
1845            coref_b: false,
1846            url: None,
1847        };
1848
1849        let doc = example.to_coref_document();
1850
1851        // Verify mention types are correctly assigned
1852        let all_mentions: Vec<_> = doc.chains.iter().flat_map(|c| &c.mentions).collect();
1853        assert_eq!(all_mentions.len(), 3);
1854
1855        // Proper nouns: Alice, Bob
1856        let proper_count = all_mentions
1857            .iter()
1858            .filter(|m| m.mention_type == Some(MentionType::Proper))
1859            .count();
1860        assert_eq!(
1861            proper_count, 2,
1862            "Should have 2 proper noun mentions (Alice, Bob)"
1863        );
1864
1865        // Pronominal: She
1866        let pronominal_count = all_mentions
1867            .iter()
1868            .filter(|m| m.mention_type == Some(MentionType::Pronominal))
1869            .count();
1870        assert_eq!(
1871            pronominal_count, 1,
1872            "Should have 1 pronominal mention (She)"
1873        );
1874    }
1875
1876    #[test]
1877    fn test_synthetic_coref_dataset() {
1878        let docs = synthetic_coref_dataset(5);
1879        assert_eq!(docs.len(), 5);
1880
1881        for doc in &docs {
1882            assert!(!doc.text.is_empty());
1883            assert!(!doc.chains.is_empty());
1884        }
1885    }
1886
1887    #[test]
1888    fn test_adversarial_examples() {
1889        let examples = adversarial_coref_examples();
1890        assert!(!examples.is_empty());
1891
1892        for (gold, pred, name) in &examples {
1893            assert!(!gold.chains.is_empty(), "Gold chains empty for {}", name);
1894            assert!(!pred.chains.is_empty(), "Pred chains empty for {}", name);
1895        }
1896    }
1897
1898    #[test]
1899    fn test_gap_tsv_parsing() {
1900        let tsv = "ID\tText\tPronoun\tPronoun-offset\tA\tA-offset\tA-coref\tB\tB-offset\tB-coref\tURL\n\
1901                   1\tJohn saw Mary. He waved.\tHe\t15\tJohn\t0\tTRUE\tMary\t9\tFALSE\thttps://example.com";
1902
1903        let examples = parse_gap_tsv(tsv).unwrap();
1904        assert_eq!(examples.len(), 1);
1905        assert_eq!(examples[0].id, "1");
1906        assert!(examples[0].coref_a);
1907        assert!(!examples[0].coref_b);
1908    }
1909
1910    #[test]
1911    fn test_bookcoref_json_parsing() {
1912        // BookCoref format: nested sentences, token-span clusters
1913        // Use single line to test JSONL parsing
1914        let json = r#"{"doc_key": "test_book_1", "gutenberg_key": "1", "sentences": [["Alice", "met", "Bob", "."], ["She", "waved", "."]], "clusters": [[[0, 0], [4, 4]], [[2, 2]]], "characters": [{"name": "Alice", "cluster": [[0, 0], [4, 4]]}]}"#;
1915
1916        let docs = parse_bookcoref_json(json).unwrap();
1917        assert_eq!(docs.len(), 1);
1918
1919        let doc = &docs[0];
1920        // Text should be tokens joined by space
1921        assert!(doc.text.contains("Alice"));
1922        assert!(doc.text.contains("She"));
1923
1924        // Should have 2 clusters: Alice+She, Bob
1925        assert_eq!(doc.chain_count(), 2);
1926
1927        // First cluster should have 2 mentions (Alice, She)
1928        let alice_cluster = doc
1929            .chains
1930            .iter()
1931            .find(|c| c.mentions.iter().any(|m| m.text == "Alice"));
1932        assert!(alice_cluster.is_some());
1933        assert_eq!(alice_cluster.unwrap().mentions.len(), 2);
1934
1935        // Second cluster should have 1 mention (Bob)
1936        let bob_cluster = doc
1937            .chains
1938            .iter()
1939            .find(|c| c.mentions.iter().any(|m| m.text == "Bob"));
1940        assert!(bob_cluster.is_some());
1941        assert_eq!(bob_cluster.unwrap().mentions.len(), 1);
1942    }
1943
1944    #[test]
1945    fn test_bookcoref_json_array_parsing() {
1946        // Test JSON array format
1947        let json_array = r#"[{"doc_key": "book1", "sentences": [["He", "ran", "."]], "clusters": [[[0, 0]]]}, {"doc_key": "book2", "sentences": [["She", "walked", "."]], "clusters": [[[0, 0]]]}]"#;
1948
1949        let docs = parse_bookcoref_json(json_array).unwrap();
1950        assert_eq!(docs.len(), 2);
1951    }
1952
1953    #[test]
1954    fn test_bookcoref_jsonl_parsing() {
1955        // Test JSONL format (one JSON per line)
1956        let jsonl = r#"{"doc_key": "book1", "sentences": [["He", "ran", "."]], "clusters": [[[0, 0]]]}
1957{"doc_key": "book2", "sentences": [["She", "walked", "."]], "clusters": [[[0, 0]]]}"#;
1958
1959        let docs = parse_bookcoref_json(jsonl).unwrap();
1960        assert_eq!(docs.len(), 2);
1961    }
1962
1963    #[test]
1964    fn test_corefud_conllu_parsing_multilingual_and_zero() {
1965        // A tiny CorefUD-like CoNLL-U with:
1966        // - multiple documents via # newdoc id
1967        // - multi-script tokens
1968        // - one empty node (ID with .) representing a zero mention
1969        //
1970        // Entity bracketing follows CorefUD 1.0/1.2 format:
1971        // - open: Entity=(eX-type-...)
1972        // - close: Entity=eX)
1973        // - one-token mention: Entity=(eX-type-1)
1974        let conllu = r#"# newdoc id = doc_en
1975# sent_id = 1
19761	Marie	_	PROPN	_	_	0	root	_	Entity=(e1-person-1
19772	Curie	_	PROPN	_	_	1	flat	_	Entity=e1)
19783	met	_	VERB	_	_	1	dep	_	_
19794	Cher	_	PROPN	_	_	3	obj	_	Entity=(e2-person-1)
19805	.	_	PUNCT	_	_	3	punct	_	_
1981
1982# newdoc id = doc_multi
1983# sent_id = 1
19841	習近平	_	PROPN	_	_	0	root	_	Entity=(e10-person-1)
19852	在	_	ADP	_	_	1	case	_	_
19863	北京	_	PROPN	_	_	1	obl	_	Entity=(e11-place-1)
19874	會見	_	VERB	_	_	1	dep	_	_
19885	了	_	AUX	_	_	4	aux	_	_
19896	普京	_	PROPN	_	_	4	obj	_	Entity=(e12-person-1)
19907	。	_	PUNCT	_	_	4	punct	_	_
19918.1	_	_	_	_	_	_	_	_	Entity=(e13-person-1)
1992
1993# newdoc id = doc_ar
1994# sent_id = 1
19951	محمد	_	PROPN	_	_	0	root	_	Entity=(e20-person-1
19962	بن	_	PART	_	_	1	flat	_	SpaceAfter=No
19973	سلمان	_	PROPN	_	_	1	flat	_	Entity=e20)
19984	.	_	PUNCT	_	_	1	punct	_	_
1999
2000# newdoc id = doc_ru
2001# sent_id = 1
20021	Путин	_	PROPN	_	_	0	root	_	Entity=(e30-person-1)
20032	встретился	_	VERB	_	_	1	dep	_	_
20043	с	_	ADP	_	_	1	case	_	_
20054	Си	_	PROPN	_	_	1	obl	_	Entity=(e31-person-1
20065	Цзиньпином	_	PROPN	_	_	4	flat	_	Entity=e31)
20076	.	_	PUNCT	_	_	1	punct	_	_
2008
2009# newdoc id = doc_hi
2010# sent_id = 1
20111	प्रधानमंत्री	_	NOUN	_	_	0	root	_	Entity=(e40-person-1
20122	शर्मा	_	PROPN	_	_	1	flat	_	Entity=e40)
20133	दिल्ली	_	PROPN	_	_	1	obl	_	Entity=(e41-place-1)
20144	में	_	ADP	_	_	3	case	_	_
20155	थे	_	AUX	_	_	1	cop	_	_
20166	।	_	PUNCT	_	_	1	punct	_	_
2017"#;
2018
2019        let docs = parse_corefud_conllu(conllu).unwrap();
2020        assert_eq!(docs.len(), 5);
2021
2022        // doc_en: spans should be valid char offsets.
2023        let doc_en = docs
2024            .iter()
2025            .find(|d| d.doc_id.as_deref() == Some("doc_en"))
2026            .unwrap();
2027        let char_len = doc_en.text.chars().count();
2028        for m in doc_en.all_mentions() {
2029            assert!(m.start <= m.end);
2030            assert!(m.end <= char_len);
2031        }
2032
2033        // doc_multi: should include a zero mention (empty node 8.1)
2034        let doc_multi = docs
2035            .iter()
2036            .find(|d| d.doc_id.as_deref() == Some("doc_multi"))
2037            .unwrap();
2038        let zeros: Vec<_> = doc_multi
2039            .all_mentions()
2040            .into_iter()
2041            .filter(|m| m.mention_type == Some(MentionType::Zero) || (m.start == m.end))
2042            .collect();
2043        assert!(
2044            !zeros.is_empty(),
2045            "Expected at least one zero/empty mention in doc_multi"
2046        );
2047
2048        // doc_ar: SpaceAfter=No should glue بن + سلمان without inserting a space after بن.
2049        let doc_ar = docs
2050            .iter()
2051            .find(|d| d.doc_id.as_deref() == Some("doc_ar"))
2052            .unwrap();
2053        assert!(
2054            doc_ar.text.contains("محمد بنسلمان") || doc_ar.text.contains("محمد بن سلمان"),
2055            "Arabic spacing should be Unicode-safe; got: {:?}",
2056            doc_ar.text
2057        );
2058    }
2059
2060    #[test]
2061    fn test_parse_ecb_plus_coref() {
2062        let csv = "\
2063Topic,File,Sentence Number,Token Number,Token,Lemma,Event Mention,Coreference Chain
20641,1ecb,0,0,The,the,,
20651,1ecb,0,1,earthquake,earthquake,ACT,1
20661,1ecb,0,2,struck,strike,ACT,2
20671,1ecb,0,3,at,at,,
20681,1ecb,0,4,dawn,dawn,,
20691,1ecb,1,0,A,a,,
20701,1ecb,1,1,tremor,tremor,ACT,2
20711,1ecb,1,2,was,be,,
20721,1ecb,1,3,felt,feel,ACT,
20731,2ecb,0,0,The,the,,
20741,2ecb,0,1,quake,quake,ACT,1
20751,2ecb,0,2,damaged,damage,ACT,3
20761,2ecb,0,3,buildings,building,,
2077";
2078        let docs = parse_ecb_plus_coref(csv).unwrap();
2079
2080        // Should produce 2 documents: (1, 1ecb) and (1, 2ecb)
2081        assert_eq!(docs.len(), 2);
2082
2083        let doc1 = docs
2084            .iter()
2085            .find(|d| d.doc_id.as_deref() == Some("1_1ecb"))
2086            .unwrap();
2087        let doc2 = docs
2088            .iter()
2089            .find(|d| d.doc_id.as_deref() == Some("1_2ecb"))
2090            .unwrap();
2091
2092        // doc1 has chains for chain IDs "1" and "2"
2093        assert_eq!(doc1.chains.len(), 2);
2094        // doc2 has chains for chain IDs "1" and "3"
2095        assert_eq!(doc2.chains.len(), 2);
2096
2097        // Chain "1" in doc1 should contain "earthquake"
2098        let chain1 = doc1
2099            .chains
2100            .iter()
2101            .find(|c| c.mentions.iter().any(|m| m.text == "earthquake"));
2102        assert!(chain1.is_some());
2103
2104        // Chain "2" in doc1 should contain "struck" and "tremor"
2105        let chain2 = doc1
2106            .chains
2107            .iter()
2108            .find(|c| c.mentions.iter().any(|m| m.text == "struck"));
2109        assert!(chain2.is_some());
2110        assert!(chain2.unwrap().mentions.iter().any(|m| m.text == "tremor"));
2111
2112        // Chain "1" in doc2 should contain "quake" (cross-doc coreferent with "earthquake")
2113        let chain1_doc2 = doc2
2114            .chains
2115            .iter()
2116            .find(|c| c.mentions.iter().any(|m| m.text == "quake"));
2117        assert!(chain1_doc2.is_some());
2118    }
2119
2120    #[test]
2121    fn test_parse_ecb_plus_xml() {
2122        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
2123<Document doc_name="1_1ecb" doc_id="1_1ecb.xml.xml">
2124  <token t_id="1" sentence="0" number="0">The</token>
2125  <token t_id="2" sentence="0" number="1">earthquake</token>
2126  <token t_id="3" sentence="0" number="2">struck</token>
2127  <token t_id="4" sentence="0" number="3">at</token>
2128  <token t_id="5" sentence="0" number="4">dawn</token>
2129  <token t_id="6" sentence="1" number="0">A</token>
2130  <token t_id="7" sentence="1" number="1">tremor</token>
2131  <token t_id="8" sentence="1" number="2">was</token>
2132  <token t_id="9" sentence="1" number="3">felt</token>
2133  <Markables>
2134    <ACTION_OCCURRENCE m_id="30">
2135      <token_anchor t_id="2"/>
2136    </ACTION_OCCURRENCE>
2137    <ACTION_OCCURRENCE m_id="31">
2138      <token_anchor t_id="3"/>
2139    </ACTION_OCCURRENCE>
2140    <ACTION_OCCURRENCE m_id="32">
2141      <token_anchor t_id="7"/>
2142    </ACTION_OCCURRENCE>
2143  </Markables>
2144  <Relations>
2145    <CROSS_DOC_COREF r_id="1" note="30001">
2146      <source m_id="30"/>
2147      <target m_id="32"/>
2148    </CROSS_DOC_COREF>
2149    <CROSS_DOC_COREF r_id="2" note="30002">
2150      <source m_id="31"/>
2151    </CROSS_DOC_COREF>
2152  </Relations>
2153</Document>"#;
2154
2155        let doc = parse_ecb_plus_xml(xml, "1", "1_1ecb").unwrap();
2156        assert_eq!(doc.doc_id.as_deref(), Some("1_1_1ecb"));
2157        assert!(doc.text.contains("earthquake"));
2158        assert!(doc.text.contains("tremor"));
2159
2160        // Cluster 30001 should have mentions for "earthquake" (t_id=2) and "tremor" (t_id=7)
2161        let cluster_30001 = doc.chains.iter().find(|c| {
2162            c.mentions.iter().any(|m| m.text == "earthquake")
2163                && c.mentions.iter().any(|m| m.text == "tremor")
2164        });
2165        assert!(
2166            cluster_30001.is_some(),
2167            "Expected cross-doc cluster linking earthquake and tremor"
2168        );
2169
2170        // Cluster 30002 should have "struck"
2171        let cluster_30002 = doc
2172            .chains
2173            .iter()
2174            .find(|c| c.mentions.iter().any(|m| m.text == "struck"));
2175        assert!(cluster_30002.is_some(), "Expected cluster for struck");
2176    }
2177}