Skip to main content

anno_eval/eval/
validation.rs

1//! Validation utilities for NER evaluation.
2//!
3//! Provides validation for:
4//! - Ground truth entity spans (bounds checking, non-overlapping)
5//! - Entity type consistency
6//! - Text bounds validation
7//! - Overlapping entity detection
8
9use super::datasets::GoldEntity;
10use anno::EntityType;
11use anno::{Error, Result};
12
13/// Validation result for ground truth entities.
14#[derive(Debug, Clone)]
15pub struct ValidationResult {
16    /// Whether validation passed
17    pub is_valid: bool,
18    /// Validation errors found
19    pub errors: Vec<String>,
20    /// Validation warnings
21    pub warnings: Vec<String>,
22}
23
24impl ValidationResult {
25    /// Create a new validation result.
26    #[must_use]
27    pub fn new() -> Self {
28        Self {
29            is_valid: true,
30            errors: Vec::new(),
31            warnings: Vec::new(),
32        }
33    }
34
35    /// Add an error.
36    pub fn add_error(&mut self, error: String) {
37        self.is_valid = false;
38        self.errors.push(error);
39    }
40
41    /// Add a warning.
42    pub fn add_warning(&mut self, warning: String) {
43        self.warnings.push(warning);
44    }
45
46    /// Convert to Result, returning error if validation failed.
47    pub fn into_result(self) -> Result<()> {
48        if self.is_valid {
49            Ok(())
50        } else {
51            Err(Error::InvalidInput(format!(
52                "Validation failed: {}",
53                self.errors.join("; ")
54            )))
55        }
56    }
57}
58
59impl Default for ValidationResult {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65/// Validate ground truth entities against text.
66///
67/// Checks:
68/// - Entity spans are within text bounds
69/// - Entity spans are non-empty (start < end)
70/// - Entity text matches the span in the text
71/// - Entities don't overlap (optional, can be warning)
72///
73/// # Arguments
74/// * `text` - The text being annotated
75/// * `entities` - Ground truth entities to validate
76/// * `strict` - If true, overlapping entities are errors; if false, warnings
77///
78/// # Returns
79/// Validation result with errors and warnings
80pub fn validate_ground_truth_entities(
81    text: &str,
82    entities: &[GoldEntity],
83    strict: bool,
84) -> ValidationResult {
85    let mut result = ValidationResult::new();
86    // Use character count for Unicode correctness (matches GoldEntity offsets)
87    let text_char_len = text.chars().count();
88
89    // Check each entity
90    for (i, entity) in entities.iter().enumerate() {
91        // Check for whitespace-only entities
92        if entity.text.trim().is_empty() {
93            result.add_warning(format!(
94                "Entity {}: text is empty or whitespace-only: '{}'",
95                i, entity.text
96            ));
97        }
98
99        // Check bounds (using character count, not byte count)
100        if entity.start >= text_char_len {
101            result.add_error(format!(
102                "Entity {}: start position {} out of bounds (text length: {} chars)",
103                i, entity.start, text_char_len
104            ));
105            continue;
106        }
107
108        if entity.end > text_char_len {
109            result.add_error(format!(
110                "Entity {}: end position {} out of bounds (text length: {} chars)",
111                i, entity.end, text_char_len
112            ));
113            continue;
114        }
115
116        // Check non-empty span
117        if entity.start >= entity.end {
118            result.add_error(format!(
119                "Entity {}: invalid span (start {} >= end {})",
120                i, entity.start, entity.end
121            ));
122            continue;
123        }
124
125        // Check text matches span (using character offsets, not byte offsets)
126        let span_text: String = text
127            .chars()
128            .skip(entity.start)
129            .take(entity.end - entity.start)
130            .collect();
131        if span_text != entity.text {
132            result.add_warning(format!(
133                "Entity {}: text mismatch. Expected '{}', found '{}'",
134                i, entity.text, span_text
135            ));
136        }
137    }
138
139    // Check for overlapping entities
140    for i in 0..entities.len() {
141        for j in (i + 1)..entities.len() {
142            let e1 = &entities[i];
143            let e2 = &entities[j];
144
145            // Check if spans overlap
146            let overlap = (e1.start < e2.end) && (e2.start < e1.end);
147            if overlap {
148                let msg = format!(
149                    "Entities {} and {} overlap: [{}, {}) and [{}, {})",
150                    i, j, e1.start, e1.end, e2.start, e2.end
151                );
152                if strict {
153                    result.add_error(msg);
154                } else {
155                    result.add_warning(msg);
156                }
157            }
158        }
159    }
160
161    result
162}
163
164/// Validate entity type consistency across test cases.
165///
166/// Checks that entity types are used consistently (e.g., same type name
167/// refers to same EntityType variant).
168pub fn validate_entity_type_consistency(
169    test_cases: &[(String, Vec<GoldEntity>)],
170) -> ValidationResult {
171    let mut result = ValidationResult::new();
172    let mut type_map: std::collections::HashMap<String, EntityType> =
173        std::collections::HashMap::new();
174
175    for (case_idx, (_text, entities)) in test_cases.iter().enumerate() {
176        for entity in entities {
177            let type_str = crate::eval::entity_type_to_string(&entity.entity_type);
178            if let Some(existing_type) = type_map.get(&type_str) {
179                // Check if types match
180                if !crate::eval::entity_type_matches(existing_type, &entity.entity_type) {
181                    result.add_warning(format!(
182                        "Test case {}: Entity type '{}' inconsistent with previous usage",
183                        case_idx, type_str
184                    ));
185                }
186            } else {
187                type_map.insert(type_str, entity.entity_type.clone());
188            }
189        }
190    }
191
192    result
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn test_validate_bounds() {
201        let text = "Hello world";
202        let entities = vec![GoldEntity {
203            text: "Hello".to_string(),
204            entity_type: EntityType::Person,
205            original_label: "PER".to_string(),
206            start: 0,
207            end: 5,
208        }];
209
210        let result = validate_ground_truth_entities(text, &entities, false);
211        assert!(result.is_valid);
212    }
213
214    #[test]
215    fn test_validate_out_of_bounds() {
216        let text = "Hello";
217        let entities = vec![GoldEntity {
218            text: "world".to_string(),
219            entity_type: EntityType::Person,
220            original_label: "PER".to_string(),
221            start: 10,
222            end: 15,
223        }];
224
225        let result = validate_ground_truth_entities(text, &entities, false);
226        assert!(!result.is_valid);
227        assert!(!result.errors.is_empty());
228    }
229
230    #[test]
231    fn test_validate_overlapping() {
232        let text = "Hello world";
233        let entities = vec![
234            GoldEntity {
235                text: "Hello".to_string(),
236                entity_type: EntityType::Person,
237                original_label: "PER".to_string(),
238                start: 0,
239                end: 5,
240            },
241            GoldEntity {
242                text: "lo wo".to_string(),
243                entity_type: EntityType::Person,
244                original_label: "PER".to_string(),
245                start: 3,
246                end: 8,
247            },
248        ];
249
250        let result = validate_ground_truth_entities(text, &entities, false);
251        assert!(result.is_valid); // Warnings don't fail validation
252        assert!(!result.warnings.is_empty());
253
254        let result_strict = validate_ground_truth_entities(text, &entities, true);
255        assert!(!result_strict.is_valid); // Errors fail validation
256    }
257}