Skip to main content

anno_eval/eval/
dataset_comparison.rs

1//! Dataset comparison for understanding distribution differences.
2//!
3//! Compares two NER datasets to understand:
4//! - Entity type distribution differences
5//! - Vocabulary overlap/divergence
6//! - Entity length characteristics
7//! - Difficulty estimation
8//!
9//! Useful for:
10//! - Understanding domain gaps before cross-domain evaluation
11//! - Selecting transfer learning source datasets
12//! - Debugging performance differences across corpora
13//!
14//! # Example
15//!
16//! ```rust
17//! use anno_eval::eval::dataset_comparison::{DatasetStats, compare_datasets};
18//! use anno_eval::eval::synthetic::AnnotatedExample;
19//!
20//! let dataset_a = vec![
21//!     AnnotatedExample::from_tuples("John works at Google.", vec![("John", "PER"), ("Google", "ORG")]),
22//! ];
23//! let dataset_b = vec![
24//!     AnnotatedExample::from_tuples("Paris is beautiful.", vec![("Paris", "LOC")]),
25//! ];
26//!
27//! let comparison = compare_datasets(&dataset_a, &dataset_b);
28//! println!("Type distribution divergence: {:.3}", comparison.type_divergence);
29//! println!("Vocabulary overlap: {:.1}%", comparison.vocab_overlap * 100.0);
30//! ```
31
32use crate::eval::synthetic::AnnotatedExample;
33use serde::{Deserialize, Serialize};
34use std::collections::{HashMap, HashSet};
35
36// =============================================================================
37// Dataset Statistics
38// =============================================================================
39
40/// Statistics about a single dataset.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct DatasetStats {
43    /// Number of examples
44    pub num_examples: usize,
45    /// Total number of entities
46    pub num_entities: usize,
47    /// Entity type distribution (type -> proportion)
48    pub type_distribution: HashMap<String, f64>,
49    /// Average entities per example
50    pub avg_entities_per_example: f64,
51    /// Vocabulary (unique tokens)
52    pub vocab_size: usize,
53    /// Entity length distribution stats
54    pub entity_length_stats: LengthStats,
55    /// Unique entity texts
56    pub unique_entity_texts: usize,
57    /// Entity text repetition rate (1.0 = all unique, lower = more repetition)
58    pub entity_diversity: f64,
59}
60
61/// Statistics about entity lengths.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct LengthStats {
64    /// Mean entity length in tokens
65    pub mean: f64,
66    /// Median entity length
67    pub median: f64,
68    /// Standard deviation
69    pub std_dev: f64,
70    /// Minimum length
71    pub min: usize,
72    /// Maximum length
73    pub max: usize,
74}
75
76/// Comparison between two datasets.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct DatasetComparison {
79    /// Stats for dataset A
80    pub stats_a: DatasetStats,
81    /// Stats for dataset B
82    pub stats_b: DatasetStats,
83    /// Jensen-Shannon divergence of type distributions (0 = identical, 1 = disjoint)
84    pub type_divergence: f64,
85    /// Vocabulary overlap (Jaccard similarity)
86    pub vocab_overlap: f64,
87    /// Entity text overlap (Jaccard similarity)
88    pub entity_text_overlap: f64,
89    /// Types present in A but not B
90    pub types_only_in_a: Vec<String>,
91    /// Types present in B but not A
92    pub types_only_in_b: Vec<String>,
93    /// Estimated domain gap (heuristic combining multiple factors)
94    pub estimated_domain_gap: f64,
95    /// Recommendations for transfer learning
96    pub recommendations: Vec<String>,
97}
98
99// =============================================================================
100// Statistics Computation
101// =============================================================================
102
103/// Compute statistics for a dataset.
104pub fn compute_stats(examples: &[AnnotatedExample]) -> DatasetStats {
105    if examples.is_empty() {
106        return DatasetStats {
107            num_examples: 0,
108            num_entities: 0,
109            type_distribution: HashMap::new(),
110            avg_entities_per_example: 0.0,
111            vocab_size: 0,
112            entity_length_stats: LengthStats {
113                mean: 0.0,
114                median: 0.0,
115                std_dev: 0.0,
116                min: 0,
117                max: 0,
118            },
119            unique_entity_texts: 0,
120            entity_diversity: 1.0,
121        };
122    }
123
124    let mut type_counts: HashMap<String, usize> = HashMap::new();
125    let mut vocab: HashSet<String> = HashSet::new();
126    let mut entity_texts: HashSet<String> = HashSet::new();
127    let mut entity_lengths: Vec<usize> = Vec::new();
128    let mut total_entities = 0;
129
130    for example in examples {
131        // Collect vocabulary
132        for token in example.text.split_whitespace() {
133            vocab.insert(token.to_lowercase());
134        }
135
136        // Collect entity stats
137        for entity in &example.entities {
138            total_entities += 1;
139            *type_counts
140                .entry(entity.entity_type.to_string())
141                .or_insert(0) += 1;
142            entity_texts.insert(entity.text.to_lowercase());
143
144            // Count tokens in entity
145            let token_count = entity.text.split_whitespace().count().max(1);
146            entity_lengths.push(token_count);
147        }
148    }
149
150    // Compute type distribution
151    let type_distribution: HashMap<String, f64> = type_counts
152        .iter()
153        .map(|(t, c)| (t.clone(), *c as f64 / total_entities.max(1) as f64))
154        .collect();
155
156    // Compute length stats
157    let entity_length_stats = if entity_lengths.is_empty() {
158        LengthStats {
159            mean: 0.0,
160            median: 0.0,
161            std_dev: 0.0,
162            min: 0,
163            max: 0,
164        }
165    } else {
166        let mut sorted = entity_lengths.clone();
167        sorted.sort_unstable();
168
169        let mean = entity_lengths.iter().sum::<usize>() as f64 / entity_lengths.len() as f64;
170        let median = sorted[sorted.len() / 2] as f64;
171        let variance = entity_lengths
172            .iter()
173            .map(|&l| (l as f64 - mean).powi(2))
174            .sum::<f64>()
175            / entity_lengths.len() as f64;
176        let std_dev = variance.sqrt();
177
178        LengthStats {
179            mean,
180            median,
181            std_dev,
182            min: *sorted.first().unwrap_or(&0),
183            max: *sorted.last().unwrap_or(&0),
184        }
185    };
186
187    DatasetStats {
188        num_examples: examples.len(),
189        num_entities: total_entities,
190        type_distribution,
191        avg_entities_per_example: total_entities as f64 / examples.len() as f64,
192        vocab_size: vocab.len(),
193        entity_length_stats,
194        unique_entity_texts: entity_texts.len(),
195        entity_diversity: entity_texts.len() as f64 / total_entities.max(1) as f64,
196    }
197}
198
199/// Compare two datasets.
200pub fn compare_datasets(a: &[AnnotatedExample], b: &[AnnotatedExample]) -> DatasetComparison {
201    let stats_a = compute_stats(a);
202    let stats_b = compute_stats(b);
203
204    // Collect vocabularies
205    let vocab_a: HashSet<String> = a
206        .iter()
207        .flat_map(|e| e.text.split_whitespace().map(|t| t.to_lowercase()))
208        .collect();
209    let vocab_b: HashSet<String> = b
210        .iter()
211        .flat_map(|e| e.text.split_whitespace().map(|t| t.to_lowercase()))
212        .collect();
213
214    // Collect entity texts
215    let entities_a: HashSet<String> = a
216        .iter()
217        .flat_map(|e| e.entities.iter().map(|ent| ent.text.to_lowercase()))
218        .collect();
219    let entities_b: HashSet<String> = b
220        .iter()
221        .flat_map(|e| e.entities.iter().map(|ent| ent.text.to_lowercase()))
222        .collect();
223
224    // Vocabulary overlap (Jaccard)
225    let vocab_intersection = vocab_a.intersection(&vocab_b).count();
226    let vocab_union = vocab_a.union(&vocab_b).count();
227    let vocab_overlap = if vocab_union == 0 {
228        1.0
229    } else {
230        vocab_intersection as f64 / vocab_union as f64
231    };
232
233    // Entity text overlap (Jaccard)
234    let entity_intersection = entities_a.intersection(&entities_b).count();
235    let entity_union = entities_a.union(&entities_b).count();
236    let entity_text_overlap = if entity_union == 0 {
237        1.0
238    } else {
239        entity_intersection as f64 / entity_union as f64
240    };
241
242    // Type distribution divergence (Jensen-Shannon)
243    let type_divergence =
244        jensen_shannon_divergence(&stats_a.type_distribution, &stats_b.type_distribution);
245
246    // Types in one but not the other
247    let types_a: HashSet<&String> = stats_a.type_distribution.keys().collect();
248    let types_b: HashSet<&String> = stats_b.type_distribution.keys().collect();
249    let types_only_in_a: Vec<String> = types_a.difference(&types_b).map(|s| (*s).clone()).collect();
250    let types_only_in_b: Vec<String> = types_b.difference(&types_a).map(|s| (*s).clone()).collect();
251
252    // Estimated domain gap (heuristic)
253    let estimated_domain_gap =
254        0.4 * type_divergence + 0.3 * (1.0 - vocab_overlap) + 0.3 * (1.0 - entity_text_overlap);
255
256    // Generate recommendations
257    let recommendations = generate_recommendations(
258        type_divergence,
259        vocab_overlap,
260        entity_text_overlap,
261        &types_only_in_a,
262        &types_only_in_b,
263    );
264
265    DatasetComparison {
266        stats_a,
267        stats_b,
268        type_divergence,
269        vocab_overlap,
270        entity_text_overlap,
271        types_only_in_a,
272        types_only_in_b,
273        estimated_domain_gap,
274        recommendations,
275    }
276}
277
278fn jensen_shannon_divergence(p: &HashMap<String, f64>, q: &HashMap<String, f64>) -> f64 {
279    // Collect all keys
280    let all_keys: HashSet<&String> = p.keys().chain(q.keys()).collect();
281
282    if all_keys.is_empty() {
283        return 0.0;
284    }
285
286    // Compute M = (P + Q) / 2
287    let mut m: HashMap<&String, f64> = HashMap::new();
288    for k in &all_keys {
289        let p_val = p.get(*k).copied().unwrap_or(0.0);
290        let q_val = q.get(*k).copied().unwrap_or(0.0);
291        m.insert(*k, (p_val + q_val) / 2.0);
292    }
293
294    // Compute KL(P || M) and KL(Q || M)
295    let kl_p_m: f64 = all_keys
296        .iter()
297        .map(|k| {
298            let p_val = p.get(*k).copied().unwrap_or(0.0);
299            let m_val = m.get(k).copied().unwrap_or(1e-10);
300            if p_val > 0.0 {
301                p_val * (p_val / m_val).ln()
302            } else {
303                0.0
304            }
305        })
306        .sum();
307
308    let kl_q_m: f64 = all_keys
309        .iter()
310        .map(|k| {
311            let q_val = q.get(*k).copied().unwrap_or(0.0);
312            let m_val = m.get(k).copied().unwrap_or(1e-10);
313            if q_val > 0.0 {
314                q_val * (q_val / m_val).ln()
315            } else {
316                0.0
317            }
318        })
319        .sum();
320
321    // JS divergence = (KL(P||M) + KL(Q||M)) / 2
322    // Normalize to [0, 1] by dividing by ln(2)
323    ((kl_p_m + kl_q_m) / 2.0) / 2.0_f64.ln()
324}
325
326fn generate_recommendations(
327    type_div: f64,
328    vocab_overlap: f64,
329    entity_overlap: f64,
330    types_only_a: &[String],
331    types_only_b: &[String],
332) -> Vec<String> {
333    let mut recs = Vec::new();
334
335    if type_div > 0.5 {
336        recs.push("High type distribution divergence - consider domain adaptation".into());
337    } else if type_div > 0.2 {
338        recs.push("Moderate type divergence - transfer learning may require fine-tuning".into());
339    }
340
341    if vocab_overlap < 0.3 {
342        recs.push("Low vocabulary overlap - domains use different terminology".into());
343    }
344
345    if entity_overlap < 0.1 {
346        recs.push("Very few shared entities - gazetteer transfer unlikely to help".into());
347    }
348
349    if !types_only_a.is_empty() {
350        recs.push(format!(
351            "Types in source only: {:?} - target may not need these",
352            types_only_a
353        ));
354    }
355
356    if !types_only_b.is_empty() {
357        recs.push(format!(
358            "Types in target only: {:?} - source cannot help with these",
359            types_only_b
360        ));
361    }
362
363    if recs.is_empty() {
364        recs.push("Datasets appear compatible for transfer learning".into());
365    }
366
367    recs
368}
369
370// =============================================================================
371// Difficulty Estimation
372// =============================================================================
373
374/// Estimate relative difficulty of a dataset.
375pub fn estimate_difficulty(stats: &DatasetStats) -> DifficultyEstimate {
376    let mut factors = Vec::new();
377    let mut score: f64 = 0.0;
378
379    // More entity types = harder
380    let num_types = stats.type_distribution.len();
381    if num_types > 10 {
382        factors.push("Many entity types (>10)".into());
383        score += 0.2;
384    } else if num_types > 5 {
385        factors.push("Moderate entity types (5-10)".into());
386        score += 0.1;
387    }
388
389    // Longer entities = harder
390    if stats.entity_length_stats.mean > 3.0 {
391        factors.push("Long average entity length (>3 tokens)".into());
392        score += 0.2;
393    }
394
395    // High variance in entity length = harder
396    if stats.entity_length_stats.std_dev > 2.0 {
397        factors.push("High entity length variance".into());
398        score += 0.1;
399    }
400
401    // Low entity diversity = easier (more repetition for learning)
402    if stats.entity_diversity > 0.9 {
403        factors.push("High entity diversity (few repeated entities)".into());
404        score += 0.2;
405    } else if stats.entity_diversity < 0.3 {
406        factors.push("Low entity diversity (model can memorize)".into());
407        score -= 0.1;
408    }
409
410    // Few entities per example = harder to learn context
411    if stats.avg_entities_per_example < 1.0 {
412        factors.push("Few entities per example (<1 avg)".into());
413        score += 0.1;
414    }
415
416    let difficulty = match score {
417        s if s < 0.2 => EstimatedDifficulty::Easy,
418        s if s < 0.4 => EstimatedDifficulty::Medium,
419        s if s < 0.6 => EstimatedDifficulty::Hard,
420        _ => EstimatedDifficulty::VeryHard,
421    };
422
423    DifficultyEstimate {
424        difficulty,
425        score: score.clamp(0.0, 1.0),
426        factors,
427    }
428}
429
430/// Estimated difficulty level based on heuristics.
431///
432/// Note: This is distinct from [`super::dataset::Difficulty`] which is
433/// manually assigned. This enum represents automatically estimated difficulty.
434#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
435pub enum EstimatedDifficulty {
436    /// Simple examples with common entities
437    Easy,
438    /// Moderate complexity
439    Medium,
440    /// Complex examples requiring context
441    Hard,
442    /// Very challenging examples (highest estimated difficulty)
443    VeryHard,
444}
445
446/// Difficulty estimate with explanation.
447#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct DifficultyEstimate {
449    /// Overall difficulty
450    pub difficulty: EstimatedDifficulty,
451    /// Numeric score (0-1, higher = harder)
452    pub score: f64,
453    /// Contributing factors
454    pub factors: Vec<String>,
455}
456
457// =============================================================================
458// =============================================================================
459// Discourse-Level Comparison
460// =============================================================================
461
462/// Statistics about discourse-level features in a dataset.
463///
464/// Useful for comparing datasets that contain abstract anaphora,
465/// event mentions, or coreference chains.
466#[cfg(feature = "discourse")]
467#[derive(Debug, Clone, Serialize, Deserialize)]
468pub struct DiscourseStats {
469    /// Number of potential abstract anaphors ("this", "that", etc.)
470    pub abstract_anaphor_count: usize,
471    /// Number of event triggers detected
472    pub event_trigger_count: usize,
473    /// Number of shell nouns detected
474    pub shell_noun_count: usize,
475    /// Average sentence length
476    pub avg_sentence_length: f64,
477    /// Number of multi-sentence examples
478    pub multi_sentence_examples: usize,
479    /// Estimated discourse complexity (0-1)
480    pub discourse_complexity: f64,
481}
482
483/// Compute discourse-level statistics for a dataset.
484#[cfg(feature = "discourse")]
485pub fn compute_discourse_stats(examples: &[AnnotatedExample]) -> DiscourseStats {
486    use crate::discourse::{classify_shell_noun, DiscourseScope, EventExtractor};
487
488    if examples.is_empty() {
489        return DiscourseStats {
490            abstract_anaphor_count: 0,
491            event_trigger_count: 0,
492            shell_noun_count: 0,
493            avg_sentence_length: 0.0,
494            multi_sentence_examples: 0,
495            discourse_complexity: 0.0,
496        };
497    }
498
499    let extractor = EventExtractor::default();
500    let mut abstract_anaphor_count = 0;
501    let mut event_trigger_count = 0;
502    let mut shell_noun_count = 0;
503    let mut total_sentences = 0;
504    let mut multi_sentence_examples = 0;
505
506    // Abstract anaphor patterns
507    let anaphor_patterns = [
508        "this ", "that ", "these ", "those ", "this.", "that.", "this,", "that,", " it ", " it.",
509        " it,",
510    ];
511
512    for example in examples {
513        let text_lower = example.text.to_lowercase();
514
515        // Count abstract anaphors
516        for pattern in &anaphor_patterns {
517            abstract_anaphor_count += text_lower.matches(pattern).count();
518        }
519
520        // Count event triggers
521        let events = extractor.extract(&example.text);
522        event_trigger_count += events.len();
523
524        // Count shell nouns
525        for word in example.text.split_whitespace() {
526            let word_clean = word.trim_matches(|c: char| !c.is_alphabetic());
527            if classify_shell_noun(word_clean).is_some() {
528                shell_noun_count += 1;
529            }
530        }
531
532        // Analyze sentence structure
533        let scope = DiscourseScope::analyze(&example.text);
534        let num_sentences = scope.sentence_count().max(1);
535        total_sentences += num_sentences;
536
537        if num_sentences > 1 {
538            multi_sentence_examples += 1;
539        }
540    }
541
542    let avg_sentence_length = examples
543        .iter()
544        .map(|e| e.text.split_whitespace().count())
545        .sum::<usize>() as f64
546        / total_sentences.max(1) as f64;
547
548    // Estimate discourse complexity
549    let complexity = ((abstract_anaphor_count as f64 / examples.len() as f64).min(1.0) * 0.3
550        + (event_trigger_count as f64 / examples.len() as f64).min(1.0) * 0.3
551        + (shell_noun_count as f64 / examples.len() as f64 / 2.0).min(1.0) * 0.2
552        + (multi_sentence_examples as f64 / examples.len() as f64) * 0.2)
553        .clamp(0.0, 1.0);
554
555    DiscourseStats {
556        abstract_anaphor_count,
557        event_trigger_count,
558        shell_noun_count,
559        avg_sentence_length,
560        multi_sentence_examples,
561        discourse_complexity: complexity,
562    }
563}
564
565/// Extended comparison including discourse-level features.
566#[cfg(feature = "discourse")]
567#[derive(Debug, Clone, Serialize, Deserialize)]
568pub struct ExtendedDatasetComparison {
569    /// Basic NER comparison
570    pub basic: DatasetComparison,
571    /// Discourse stats for dataset A
572    pub discourse_a: DiscourseStats,
573    /// Discourse stats for dataset B
574    pub discourse_b: DiscourseStats,
575    /// Discourse complexity difference
576    pub discourse_gap: f64,
577    /// Additional recommendations based on discourse analysis
578    pub discourse_recommendations: Vec<String>,
579}
580
581/// Compare datasets with extended discourse-level analysis.
582#[cfg(feature = "discourse")]
583pub fn compare_datasets_extended(
584    a: &[AnnotatedExample],
585    b: &[AnnotatedExample],
586) -> ExtendedDatasetComparison {
587    let basic = compare_datasets(a, b);
588    let discourse_a = compute_discourse_stats(a);
589    let discourse_b = compute_discourse_stats(b);
590
591    let discourse_gap = (discourse_a.discourse_complexity - discourse_b.discourse_complexity).abs();
592
593    let mut discourse_recommendations = Vec::new();
594
595    if discourse_gap > 0.3 {
596        discourse_recommendations.push(
597            "Significant discourse complexity difference - models may struggle with transfer"
598                .into(),
599        );
600    }
601
602    if discourse_a.event_trigger_count > 0 && discourse_b.event_trigger_count == 0 {
603        discourse_recommendations.push(
604            "Source has event triggers but target doesn't - event extraction may not transfer"
605                .into(),
606        );
607    }
608
609    if discourse_a.abstract_anaphor_count > discourse_b.abstract_anaphor_count * 2 {
610        discourse_recommendations
611            .push("Source has more abstract anaphora - coreference may not generalize".into());
612    }
613
614    if discourse_a.multi_sentence_examples > 0 && discourse_b.multi_sentence_examples == 0 {
615        discourse_recommendations
616            .push("Target is single-sentence only - cross-sentence phenomena won't appear".into());
617    }
618
619    if discourse_recommendations.is_empty() {
620        discourse_recommendations
621            .push("Discourse characteristics are similar between datasets".into());
622    }
623
624    ExtendedDatasetComparison {
625        basic,
626        discourse_a,
627        discourse_b,
628        discourse_gap,
629        discourse_recommendations,
630    }
631}
632
633// =============================================================================
634// Tests
635// =============================================================================
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640
641    fn make_example(text: &str, entities: Vec<(&str, &str)>) -> AnnotatedExample {
642        use crate::eval::datasets::GoldEntity;
643        use crate::eval::synthetic::{Difficulty, Domain};
644        use anno::{EntityCategory, EntityType};
645
646        let mut gold_entities = Vec::new();
647
648        for (entity_text, entity_type_str) in entities {
649            if let Some(start) = text.find(entity_text) {
650                let entity_type = match entity_type_str {
651                    "PER" => EntityType::Person,
652                    "ORG" => EntityType::Organization,
653                    "LOC" => EntityType::Location,
654                    _ => EntityType::custom(entity_type_str, EntityCategory::Misc),
655                };
656                gold_entities.push(GoldEntity::new(entity_text, entity_type, start));
657            }
658        }
659
660        AnnotatedExample {
661            text: text.to_string(),
662            entities: gold_entities,
663            domain: Domain::News,
664            difficulty: Difficulty::Easy,
665        }
666    }
667
668    #[test]
669    fn test_compute_stats_empty() {
670        let stats = compute_stats(&[]);
671        assert_eq!(stats.num_examples, 0);
672        assert_eq!(stats.num_entities, 0);
673    }
674
675    #[test]
676    fn test_compute_stats_basic() {
677        let examples = vec![
678            make_example(
679                "John works at Google.",
680                vec![("John", "PER"), ("Google", "ORG")],
681            ),
682            make_example(
683                "Paris is in France.",
684                vec![("Paris", "LOC"), ("France", "LOC")],
685            ),
686        ];
687
688        let stats = compute_stats(&examples);
689
690        assert_eq!(stats.num_examples, 2);
691        assert_eq!(stats.num_entities, 4);
692        assert_eq!(stats.avg_entities_per_example, 2.0);
693        assert!(stats.type_distribution.contains_key("PER"));
694        assert!(stats.type_distribution.contains_key("ORG"));
695        assert!(stats.type_distribution.contains_key("LOC"));
696    }
697
698    #[test]
699    fn test_compare_identical_datasets() {
700        let examples = vec![make_example(
701            "John works at Google.",
702            vec![("John", "PER"), ("Google", "ORG")],
703        )];
704
705        let comparison = compare_datasets(&examples, &examples);
706
707        assert!(comparison.type_divergence < 0.01);
708        assert!((comparison.vocab_overlap - 1.0).abs() < 0.01);
709        assert!((comparison.entity_text_overlap - 1.0).abs() < 0.01);
710    }
711
712    #[test]
713    fn test_compare_different_datasets() {
714        let a = vec![make_example("John works at Google.", vec![("John", "PER")])];
715        let b = vec![make_example("Paris is beautiful.", vec![("Paris", "LOC")])];
716
717        let comparison = compare_datasets(&a, &b);
718
719        // Different types, different vocab, different entities
720        assert!(comparison.type_divergence > 0.5);
721        assert!(comparison.vocab_overlap < 0.5);
722        assert!((comparison.entity_text_overlap - 0.0).abs() < 0.01);
723    }
724
725    #[test]
726    fn test_jensen_shannon_identical() {
727        let mut p = HashMap::new();
728        p.insert("A".into(), 0.5);
729        p.insert("B".into(), 0.5);
730
731        let js = jensen_shannon_divergence(&p, &p);
732        assert!(js < 0.01);
733    }
734
735    #[test]
736    fn test_jensen_shannon_disjoint() {
737        let mut p = HashMap::new();
738        p.insert("A".into(), 1.0);
739
740        let mut q = HashMap::new();
741        q.insert("B".into(), 1.0);
742
743        let js = jensen_shannon_divergence(&p, &q);
744        assert!(js > 0.9);
745    }
746
747    #[test]
748    fn test_difficulty_estimation() {
749        let easy_examples = vec![
750            make_example("John works here.", vec![("John", "PER")]),
751            make_example("John went home.", vec![("John", "PER")]),
752        ];
753
754        let hard_examples = vec![make_example(
755            "International Business Machines Corporation announced.",
756            vec![("International Business Machines Corporation", "ORG")],
757        )];
758
759        let easy_stats = compute_stats(&easy_examples);
760        let hard_stats = compute_stats(&hard_examples);
761
762        let easy_diff = estimate_difficulty(&easy_stats);
763        let hard_diff = estimate_difficulty(&hard_stats);
764
765        assert!(hard_diff.score >= easy_diff.score);
766    }
767
768    // =================================================================
769    // Discourse Statistics Tests
770    // =================================================================
771
772    #[test]
773    #[cfg(feature = "discourse")]
774    fn test_discourse_stats_empty() {
775        let stats = compute_discourse_stats(&[]);
776        assert_eq!(stats.abstract_anaphor_count, 0);
777        assert_eq!(stats.event_trigger_count, 0);
778        assert_eq!(stats.shell_noun_count, 0);
779    }
780
781    #[test]
782    #[cfg(feature = "discourse")]
783    fn test_discourse_stats_with_anaphors() {
784        let examples = vec![
785            make_example(
786                "Russia invaded Ukraine. This caused inflation.",
787                vec![("Russia", "LOC")],
788            ),
789            make_example(
790                "The merger was announced. That surprised investors.",
791                vec![],
792            ),
793        ];
794
795        let stats = compute_discourse_stats(&examples);
796
797        // Should detect "This" and "That" as abstract anaphors
798        assert!(
799            stats.abstract_anaphor_count >= 2,
800            "Should detect abstract anaphors"
801        );
802        // Should detect event triggers like "invaded", "announced"
803        assert!(
804            stats.event_trigger_count >= 2,
805            "Should detect event triggers"
806        );
807        // Both examples are multi-sentence
808        assert_eq!(stats.multi_sentence_examples, 2);
809    }
810
811    #[test]
812    #[cfg(feature = "discourse")]
813    fn test_discourse_stats_with_shell_nouns() {
814        let examples = vec![
815            make_example("This problem is serious.", vec![]),
816            make_example("The fact is clear.", vec![]),
817            make_example("The situation is complex.", vec![]),
818        ];
819
820        let stats = compute_discourse_stats(&examples);
821
822        // Should detect shell nouns: problem, fact, situation
823        assert!(stats.shell_noun_count >= 3, "Should detect shell nouns");
824    }
825
826    #[test]
827    #[cfg(feature = "discourse")]
828    fn test_extended_comparison() {
829        let simple = vec![make_example(
830            "John works at Google.",
831            vec![("John", "PER"), ("Google", "ORG")],
832        )];
833
834        let complex = vec![
835            make_example(
836                "Russia invaded Ukraine in 2022. This caused a global energy crisis. The situation remains tense.",
837                vec![("Russia", "LOC"), ("Ukraine", "LOC")]
838            ),
839        ];
840
841        let comparison = compare_datasets_extended(&simple, &complex);
842
843        // Complex should have higher discourse complexity
844        assert!(
845            comparison.discourse_b.discourse_complexity
846                > comparison.discourse_a.discourse_complexity,
847            "Complex dataset should have higher discourse complexity"
848        );
849
850        // Should have some discourse gap
851        assert!(comparison.discourse_gap > 0.0);
852    }
853
854    #[test]
855    #[cfg(feature = "discourse")]
856    fn test_discourse_complexity_bounds() {
857        let examples = vec![
858            make_example(
859                "This problem happened. That event occurred. This situation developed. The fact emerged.",
860                vec![]
861            ),
862        ];
863
864        let stats = compute_discourse_stats(&examples);
865
866        // Complexity should be bounded [0, 1]
867        assert!(stats.discourse_complexity >= 0.0);
868        assert!(stats.discourse_complexity <= 1.0);
869    }
870}